Repository: Wikidata/Wikidata-Toolkit (java)

Issue:
Add support for returning better error messages from the MediaWiki API
In some cases, Wikibase editing can return multiple error messages in one go:

```json
{
  "error": {
    "code": "failed-save",
    "info": "The save has failed.",
    "messages": [
      {
        "name": "wikibase-api-failed-save",
        "parameters": [],
        "html": {
          "*": "The save has failed."
        }
      },
      {
        "name": "spam-blacklisted-link",
        "parameters": [
          {
            "list": [
              "tinyurl.com/auniresc"
            ],
            "type": "text"
          }
        ],
        "html": {
          "*": "The text you wanted to publish was blocked by the spam filter.\nThis is probably caused by a link to a forbidden external site.\nThe following text is what triggered our spam filter: tinyurl.com/auniresc"
        }
      }
    ],
    "*": "See https://www.wikidata.org/w/api.php for API usage. Subscribe to the mediawiki-api-announce mailing list at &lt;https://lists.wikimedia.org/postorius/lists/mediawiki-api-announce.lists.wikimedia.org/&gt; for notice of API deprecations and breaking changes."
  },
  "servedby": "mw-api-ext.eqiad.main-79f56d66b6-6hwm5"
}
```

In this case, WDTK ignores the entire `message` part, resulting in an error message that just contains
```
    "code": "failed-save",
    "info": "The save has failed.",
```
which is not very informative. It would be good to expose the inner part of the message too, which mentions the spam blacklist.

The same structure can be found in our own `wdtk-wikibaseapi/src/test/resources`, although the message there does not bring much additional info:
```json
{
  "servedby": "mw1147",
  "error": {
    "code": "no-such-entity",
    "info": "Could not find such an entity (Invalid id: Q0)",
    "messages": [
      {
        "name": "wikibase-api-no-such-entity",
        "parameters": [],
        "html": {
          "*": "Could not find such an entity"
        }
      }
    ],
    "*": "See https://www.wikidata.org/w/api.php for API usage"
  }
}
```

Pull request description:
feat: Expose detailed error messages in MediaWiki API errors
Closes #910.

Patch:
diff --git a/.github/workflows/release_please.yml b/.github/workflows/release_please.yml
new file mode 100644
index 00000000..833ba1ae
--- /dev/null
+++ b/.github/workflows/release_please.yml
@@ -0,0 +1,24 @@
+on:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: write
+  pull-requests: write
+
+name: release-please
+
+jobs:
+  release-please:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: googleapis/release-please-action@v4
+        with:
+          # this assumes that you have created a personal access token
+          # (PAT) and configured it as a GitHub action secret named
+          # `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
+          token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
+          # this is a built-in strategy in release-please, see "Action Inputs"
+          # for more options
+          release-type: simple
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/ApiConnection.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/ApiConnection.java
index 8b515e16..3b144e08 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/ApiConnection.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/ApiConnection.java
@@ -42,9 +42,12 @@ import org.wikidata.wdtk.wikibaseapi.apierrors.AssertUserFailedException;
 import org.wikidata.wdtk.wikibaseapi.apierrors.MaxlagErrorException;
 import org.wikidata.wdtk.wikibaseapi.apierrors.MediaWikiApiErrorException;
 import org.wikidata.wdtk.wikibaseapi.apierrors.MediaWikiApiErrorHandler;
+import org.wikidata.wdtk.wikibaseapi.apierrors.MediaWikiErrorMessage;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
@@ -482,13 +485,23 @@ public abstract class ApiConnection {
 			JsonNode errorNode = root.path("error");
 			String code = errorNode.path("code").asText("UNKNOWN");
 			String info = errorNode.path("info").asText("No details provided");
+			
+			List<MediaWikiErrorMessage> messages = Collections.emptyList();
+			if (errorNode.has("messages")) {
+			    try {
+                    messages = this.mapper.treeToValue(errorNode.get("messages"), new TypeReference<List<MediaWikiErrorMessage>>() {});
+                } catch (JsonProcessingException | IllegalArgumentException e) {
+                    logger.warn("Could not parse 'messages' field of API error response");
+                }
+			}
+			
 			// Special case for the maxlag error since we also want to return
 			// the lag value in the exception thrown
 			if (errorNode.has("lag") && MediaWikiApiErrorHandler.ERROR_MAXLAG.equals(code)) {
 				double lag = errorNode.path("lag").asDouble();
 				throw new MaxlagErrorException(info, lag);
 			} else {
-				MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(code, info);
+				MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(code, info, messages);
 			}
 		}
 	}
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/AssertUserFailedException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/AssertUserFailedException.java
index 0222b97b..5661d360 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/AssertUserFailedException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/AssertUserFailedException.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -32,8 +34,13 @@ public class AssertUserFailedException extends MediaWikiApiErrorException {
 
 	private static final long serialVersionUID = -619052434831693831L;
 
+	@Deprecated(since = "0.16.0")
 	public AssertUserFailedException(String errorMessage) {
 		super(MediaWikiApiErrorHandler.ERROR_ASSERT_USER_FAILED, errorMessage);
 	}
+	
+    public AssertUserFailedException(String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super(MediaWikiApiErrorHandler.ERROR_ASSERT_USER_FAILED, errorMessage, detailedMessages);
+    }
 
 }
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/EditConflictErrorException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/EditConflictErrorException.java
index b8c8a24d..18d79007 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/EditConflictErrorException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/EditConflictErrorException.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -37,8 +39,22 @@ public class EditConflictErrorException extends MediaWikiApiErrorException {
 	 *            the error message reported by MediaWiki, or any other
 	 *            meaningful message for the user
 	 */
+	@Deprecated(since = "0.16.0")
 	public EditConflictErrorException(String errorMessage) {
 		super(MediaWikiApiErrorHandler.ERROR_EDIT_CONFLICT, errorMessage);
 	}
+	
+	/**
+     * Creates a new exception.
+     *
+     * @param errorMessage
+     *            the root error message
+     * @param detailedMessages
+     *            additional details provided by the MediaWiki API
+     */
+    public EditConflictErrorException(String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super(MediaWikiApiErrorHandler.ERROR_EDIT_CONFLICT, errorMessage, detailedMessages);
+    }
+
 
 }
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorException.java
index 3a99f0fc..c04f9375 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorException.java
@@ -1,5 +1,8 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.Collections;
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -30,11 +33,13 @@ public class MediaWikiApiErrorException extends Exception {
 
 	final String errorCode;
 	final String errorMessage;
+	final List<MediaWikiErrorMessage> detailedMessages;
 
 	private static final long serialVersionUID = 7834254856687745000L;
 
 	/**
-	 * Creates a new exception for the given error code and message.
+	 * Creates a new exception for the given error code and message, without
+	 * any detailed messages.
 	 *
 	 * @param errorCode
 	 *            MediaWiki reported error code
@@ -46,7 +51,27 @@ public class MediaWikiApiErrorException extends Exception {
 		super("[" + errorCode + "] " + errorMessage);
 		this.errorCode = errorCode;
 		this.errorMessage = errorMessage;
+		this.detailedMessages = Collections.emptyList();
 	}
+	
+	/**
+     * Creates a new exception for the given error code and message, together
+     * with detailed messages giving more insights on the error.
+     *
+     * @param errorCode
+     *            MediaWiki reported error code
+     * @param errorMessage
+     *            MediaWiki reported error message, or any other human-readable
+     *            message string generated locally
+     * @param detailedMessages
+     *            list of error messages also returned by MediaWiki (possibly empty)
+     */
+    public MediaWikiApiErrorException(String errorCode, String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super("[" + errorCode + "] " + errorMessage);
+        this.errorCode = errorCode;
+        this.errorMessage = errorMessage;
+        this.detailedMessages = detailedMessages;
+    }
 
 	/**
 	 * Returns the MediaWiki code of the error that has causes this exception.
@@ -67,5 +92,12 @@ public class MediaWikiApiErrorException extends Exception {
 	public String getErrorMessage() {
 		return this.errorMessage;
 	}
+	
+	/**
+	 * Returns the list of additional error messages returned by MediaWiki.
+	 */
+	public List<MediaWikiErrorMessage> getDetailedMessages() {
+	    return this.detailedMessages;
+	}
 
 }
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandler.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandler.java
index f274998e..f0029f91 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandler.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandler.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -36,6 +38,40 @@ public class MediaWikiApiErrorHandler {
 	public final static String ERROR_ASSERT_USER_FAILED = "assertuserfailed";
 	public final static String ERROR_TAGS_APPLY_NOT_ALLOWED = "tags-apply-not-allowed-one";
 
+	/**
+     * Creates and throws a suitable {@link MediaWikiApiErrorException} for the
+     * given error code and message.
+     *
+     * @param errorCode
+     *            the error code reported by MediaWiki
+     * @param errorMessage
+     *            the error message reported by MediaWiki, or any other
+     *            meaningful message for the user
+     * @throws MediaWikiApiErrorException
+     *             in all cases, but may throw a subclass for some errors
+     */
+    public static void throwMediaWikiApiErrorException(String errorCode,
+            String errorMessage, List<MediaWikiErrorMessage> detailedMessages) throws MediaWikiApiErrorException {
+        switch (errorCode) {
+        case ERROR_NO_TOKEN:
+        case ERROR_INVALID_TOKEN:
+            throw new TokenErrorException(errorCode, errorMessage, detailedMessages);
+        case ERROR_EDIT_CONFLICT:
+            throw new EditConflictErrorException(errorMessage, detailedMessages);
+        case ERROR_NO_SUCH_ENTITY:
+            throw new NoSuchEntityErrorException(errorMessage, detailedMessages);
+        case ERROR_MAXLAG:
+            throw new MaxlagErrorException(errorMessage);
+        case ERROR_ASSERT_USER_FAILED:
+            throw new AssertUserFailedException(errorMessage, detailedMessages);
+        case ERROR_TAGS_APPLY_NOT_ALLOWED:
+            throw new TagsApplyNotAllowedException(errorMessage, detailedMessages);
+        default:
+            throw new MediaWikiApiErrorException(errorCode, errorMessage, detailedMessages);
+        }
+    }
+        
+            
 	/**
 	 * Creates and throws a suitable {@link MediaWikiApiErrorException} for the
 	 * given error code and message.
@@ -48,6 +84,7 @@ public class MediaWikiApiErrorHandler {
 	 * @throws MediaWikiApiErrorException
 	 *             in all cases, but may throw a subclass for some errors
 	 */
+    @Deprecated(since = "0.16.0")
 	public static void throwMediaWikiApiErrorException(String errorCode,
 			String errorMessage) throws MediaWikiApiErrorException {
 		switch (errorCode) {
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessage.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessage.java
new file mode 100644
index 00000000..8d2079d9
--- /dev/null
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessage.java
@@ -0,0 +1,105 @@
+package org.wikidata.wdtk.wikibaseapi.apierrors;
+
+import java.util.Objects;
+
+/*-
+ * #%L
+ * Wikidata Toolkit Wikibase API
+ * %%
+ * Copyright (C) 2014 - 2024 Wikidata Toolkit Developers
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * An error message returned as part of the `messages` field of a broader
+ * MediaWiki error, providing more details on the source of the problem.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class MediaWikiErrorMessage {
+    
+    final String name;
+    final String htmlText;
+    
+    /**
+     * Constructor
+     * 
+     * @param name    a code for the error message, such as "spam-blacklisted-link"
+     * @param htmlText the text of the error in HTML
+     */
+    public MediaWikiErrorMessage(String name, String htmlText) {
+        this.name = name;
+        this.htmlText = htmlText;
+    }
+    
+    /**
+     * Constructor for deserialization purposes (via Jackson).
+     */
+    @JsonCreator
+    MediaWikiErrorMessage(@JsonProperty("name") String name, @JsonProperty("html") MessageWrapper wrapper) {
+        this.name = name;
+        this.htmlText = wrapper.message;
+    }
+    
+    // Wrapper class provided for deserialization purposes
+    protected static class MessageWrapper {
+        final String message;
+
+        @JsonCreator
+        public MessageWrapper(@JsonProperty("*") String message) {
+            this.message = message;
+        }
+    }
+
+    /**
+     * A code for the error message, such as "spam-blacklisted-link"
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * A human-readable error text in HTML
+     */
+    public String getHTMLText() {
+        return htmlText;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(htmlText, name);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        MediaWikiErrorMessage other = (MediaWikiErrorMessage) obj;
+        return Objects.equals(htmlText, other.htmlText) && Objects.equals(name, other.name);
+    }
+
+    @Override
+    public String toString() {
+        return "MediaWikiErrorMessage [name=" + name + ", htmlText=" + htmlText + "]";
+    }
+    
+}
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/NoSuchEntityErrorException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/NoSuchEntityErrorException.java
index 8c4f42d9..8e881dac 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/NoSuchEntityErrorException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/NoSuchEntityErrorException.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -38,8 +40,22 @@ public class NoSuchEntityErrorException extends MediaWikiApiErrorException {
 	 *            the error message reported by MediaWiki, or any other
 	 *            meaningful message for the user
 	 */
+	@Deprecated(since = "0.16.0")
 	public NoSuchEntityErrorException(String errorMessage) {
 		super(MediaWikiApiErrorHandler.ERROR_NO_SUCH_ENTITY, errorMessage);
 	}
+	
+	/**
+     * Creates a new exception.
+     *
+     * @param errorMessage
+     *            the error message reported by MediaWiki, or any other
+     *            meaningful message for the user
+     * @param detailedMessages
+     *            the more detailed messages returned by MediaWiki
+     */
+    public NoSuchEntityErrorException(String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super(MediaWikiApiErrorHandler.ERROR_NO_SUCH_ENTITY, errorMessage, detailedMessages);
+    }
 
 }
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TagsApplyNotAllowedException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TagsApplyNotAllowedException.java
index 17ad1a21..afa8e9a4 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TagsApplyNotAllowedException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TagsApplyNotAllowedException.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*-
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -36,7 +38,21 @@ public class TagsApplyNotAllowedException extends MediaWikiApiErrorException {
 	 *            the error message reported by MediaWiki, or any other
 	 *            meaningful message for the user
 	 */
+	@Deprecated(since = "0.16.0")
 	public TagsApplyNotAllowedException(String errorMessage) {
 		super(MediaWikiApiErrorHandler.ERROR_TAGS_APPLY_NOT_ALLOWED, errorMessage);
 	}
+	
+	/**
+     * Creates a new exception.
+     *
+     * @param errorMessage
+     *            the error message reported by MediaWiki, or any other
+     *            meaningful message for the user
+     * @param detailedMessages
+     *            the more detailed messages returned by MediaWiki
+     */
+    public TagsApplyNotAllowedException(String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super(MediaWikiApiErrorHandler.ERROR_TAGS_APPLY_NOT_ALLOWED, errorMessage, detailedMessages);
+    }
 }
diff --git a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TokenErrorException.java b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TokenErrorException.java
index e476303e..98b013e1 100644
--- a/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TokenErrorException.java
+++ b/wdtk-wikibaseapi/src/main/java/org/wikidata/wdtk/wikibaseapi/apierrors/TokenErrorException.java
@@ -1,5 +1,7 @@
 package org.wikidata.wdtk.wikibaseapi.apierrors;
 
+import java.util.List;
+
 /*
  * #%L
  * Wikidata Toolkit Wikibase API
@@ -40,8 +42,24 @@ public class TokenErrorException extends MediaWikiApiErrorException {
 	 *            the error message reported by MediaWiki, or any other
 	 *            meaningful message for the user
 	 */
+	@Deprecated(since = "0.16.0")
 	public TokenErrorException(String errorCode, String errorMessage) {
 		super(errorCode, errorMessage);
 	}
+	
+	/**
+     * Creates a new exception.
+     *
+     * @param errorCode
+     *            the error code reported by MediaWiki
+     * @param errorMessage
+     *            the error message reported by MediaWiki, or any other
+     *            meaningful message for the user
+     * @param detailedMessages
+     *            the more detailed error messages returned by MediaWiki
+     */
+    public TokenErrorException(String errorCode, String errorMessage, List<MediaWikiErrorMessage> detailedMessages) {
+        super(errorCode, errorMessage, detailedMessages);
+    }
 
 }


Test patch:
diff --git a/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/BasicApiConnectionTest.java b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/BasicApiConnectionTest.java
index ad5fad10..f4279f40 100644
--- a/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/BasicApiConnectionTest.java
+++ b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/BasicApiConnectionTest.java
@@ -52,6 +52,7 @@ import org.wikidata.wdtk.testing.MockStringContentFactory;
 import org.wikidata.wdtk.wikibaseapi.apierrors.AssertUserFailedException;
 import org.wikidata.wdtk.wikibaseapi.apierrors.MaxlagErrorException;
 import org.wikidata.wdtk.wikibaseapi.apierrors.MediaWikiApiErrorException;
+import org.wikidata.wdtk.wikibaseapi.apierrors.MediaWikiErrorMessage;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -328,6 +329,22 @@ public class BasicApiConnectionTest {
 			assertEquals(3.45, e.getLag(), 0.001);
 		}
 	}
+	   
+    @Test
+    public void testAbuseFilterError() throws IOException, MediaWikiApiErrorException {
+        JsonNode root;
+        URL path = this.getClass().getResource("/error-spam-filter.json");
+        root = mapper.readTree(path.openStream());
+        try {
+            connection.checkErrors(root);
+        } catch(MediaWikiApiErrorException e) {
+            List<MediaWikiErrorMessage> expectedMessages = Arrays.asList(
+                    new MediaWikiErrorMessage("wikibase-api-failed-save", "The save has failed."),
+                    new MediaWikiErrorMessage("spam-blacklisted-link", "The text you wanted to publish was blocked by the spam filter.")
+            );
+            assertEquals(expectedMessages, e.getDetailedMessages());
+        }
+    }
 
 	@Test
 	public void testClearCookies() throws LoginFailedException, IOException, MediaWikiApiErrorException {
diff --git a/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandlerTest.java b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandlerTest.java
index 90096911..8301b5f5 100644
--- a/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandlerTest.java
+++ b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiApiErrorHandlerTest.java
@@ -22,6 +22,8 @@ package org.wikidata.wdtk.wikibaseapi.apierrors;
 
 import static org.junit.Assert.assertEquals;
 
+import java.util.Collections;
+
 import org.junit.Test;
 
 public class MediaWikiApiErrorHandlerTest {
@@ -32,7 +34,7 @@ public class MediaWikiApiErrorHandlerTest {
 		String message = "";
 		try {
 			MediaWikiApiErrorHandler.throwMediaWikiApiErrorException("unknown",
-					"some message");
+					"some message", Collections.emptyList());
 		} catch (MediaWikiApiErrorException e) {
 			code = e.getErrorCode();
 			message = e.getErrorMessage();
@@ -45,32 +47,32 @@ public class MediaWikiApiErrorHandlerTest {
 	@Test(expected = TokenErrorException.class)
 	public void testNoTokenError() throws MediaWikiApiErrorException {
 		MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(
-				MediaWikiApiErrorHandler.ERROR_NO_TOKEN, "some message");
+				MediaWikiApiErrorHandler.ERROR_NO_TOKEN, "some message", Collections.emptyList());
 	}
 
 	@Test(expected = TokenErrorException.class)
 	public void testBadTokenError() throws MediaWikiApiErrorException {
 		MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(
-				MediaWikiApiErrorHandler.ERROR_INVALID_TOKEN, "some message");
+				MediaWikiApiErrorHandler.ERROR_INVALID_TOKEN, "some message", Collections.emptyList());
 	}
 
 	@Test(expected = EditConflictErrorException.class)
 	public void testEditConflictError() throws MediaWikiApiErrorException {
 		MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(
-				MediaWikiApiErrorHandler.ERROR_EDIT_CONFLICT, "some message");
+				MediaWikiApiErrorHandler.ERROR_EDIT_CONFLICT, "some message", Collections.emptyList());
 	}
 
 	@Test(expected = NoSuchEntityErrorException.class)
 	public void testNoSuchEntityError() throws MediaWikiApiErrorException {
 		MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(
-				MediaWikiApiErrorHandler.ERROR_NO_SUCH_ENTITY, "some message");
+				MediaWikiApiErrorHandler.ERROR_NO_SUCH_ENTITY, "some message", Collections.emptyList());
 	}
 
 	@Test(expected = MaxlagErrorException.class)
 	public void testMaxlagError() throws MediaWikiApiErrorException {
 		MediaWikiApiErrorHandler.throwMediaWikiApiErrorException(
 				MediaWikiApiErrorHandler.ERROR_MAXLAG,
-				"Waiting for 10.64.16.27: 2 seconds lagged");
+				"Waiting for 10.64.16.27: 2 seconds lagged", Collections.emptyList());
 	}
 
 }
diff --git a/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessageTest.java b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessageTest.java
new file mode 100644
index 00000000..4022d9d3
--- /dev/null
+++ b/wdtk-wikibaseapi/src/test/java/org/wikidata/wdtk/wikibaseapi/apierrors/MediaWikiErrorMessageTest.java
@@ -0,0 +1,43 @@
+package org.wikidata.wdtk.wikibaseapi.apierrors;
+
+/*-
+ * #%L
+ * Wikidata Toolkit Wikibase API
+ * %%
+ * Copyright (C) 2014 - 2024 Wikidata Toolkit Developers
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+import static org.junit.Assert.*;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.Test;
+
+public class MediaWikiErrorMessageTest {
+
+    @Test
+    public void testDeserialize() throws JsonMappingException, JsonProcessingException {
+        String json = "{\"name\": \"wikibase-api-failed-save\","
+                + "\"html\": {\"*\": \"The save has failed\"}}";
+        
+        ObjectMapper mapper = new ObjectMapper();
+        MediaWikiErrorMessage message = mapper.readValue(json, MediaWikiErrorMessage.class);
+        
+        assertEquals("The save has failed", message.getHTMLText());
+        assertEquals("wikibase-api-failed-save", message.getName());
+    }
+}
diff --git a/wdtk-wikibaseapi/src/test/resources/error-spam-filter.json b/wdtk-wikibaseapi/src/test/resources/error-spam-filter.json
new file mode 100644
index 00000000..3f52a1fb
--- /dev/null
+++ b/wdtk-wikibaseapi/src/test/resources/error-spam-filter.json
@@ -0,0 +1,31 @@
+{
+  "error": {
+    "code": "failed-save",
+    "info": "The save has failed.",
+    "messages": [
+      {
+        "name": "wikibase-api-failed-save",
+        "parameters": [],
+        "html": {
+          "*": "The save has failed."
+        }
+      },
+      {
+        "name": "spam-blacklisted-link",
+        "parameters": [
+          {
+            "list": [
+              "tinyurl.com/auniresc"
+            ],
+            "type": "text"
+          }
+        ],
+        "html": {
+          "*": "The text you wanted to publish was blocked by the spam filter."
+        }
+      }
+    ],
+    "*": "See https://www.wikidata.org/w/api.php for API usage. Subscribe to the mediawiki-api-announce mailing list at &lt;https://lists.wikimedia.org/postorius/lists/mediawiki-api-announce.lists.wikimedia.org/&gt; for notice of API deprecations and breaking changes."
+  },
+  "servedby": "mw-api-ext.eqiad.main-79f56d66b6-6hwm5"
+}