git » sdk » commit 8c41832

Return different errors for attachment failures

author Eric Roberts
2026-08-21 17:26:57 UTC
committer Stephen Paul Weber
2026-08-21 20:52:19 UTC
parent 706388c897bd1ee58e14d7b8b0dca9cddc2aa8a3

Return different errors for attachment failures

Everything right now is returning "failed" but there are distinct errors that
could be returned. This would allow the client to display better messaging to
the user about what went wrong.

borogove/AttachmentUploadError.hx +26 -0
borogove/AttachmentUploadErrorCode.hx +10 -0
borogove/Client.hx +83 -13
browserjs.hxml +2 -0
nodejs.hxml +2 -0
npm/index.ts +2 -0
test/FakeHttpClient.hx +38 -0
test/TestClient.hx +129 -0

diff --git a/borogove/AttachmentUploadError.hx b/borogove/AttachmentUploadError.hx
new file mode 100644
index 0000000..7217cb9
--- /dev/null
+++ b/borogove/AttachmentUploadError.hx
@@ -0,0 +1,26 @@
+package borogove;
+
+/**
+	Describes a failure while obtaining or using an HTTP Upload slot.
+
+	When `code` is `all-services-failed`, `failures` contains the per-service
+	errors in discovery order. A `no-service` error has an empty `failures`
+	array because no upload service was discovered.
+**/
+@:expose
+class AttachmentUploadError extends haxe.Exception {
+	public final code: AttachmentUploadErrorCode;
+	public final serviceId: Null<String>;
+	public final statusCode: Null<Int>;
+	public final cause: Dynamic;
+	public final failures: Array<AttachmentUploadError>;
+
+	public function new(code: AttachmentUploadErrorCode, message: String, ?serviceId: String, ?statusCode: Int, ?cause: Dynamic, ?failures: Array<AttachmentUploadError>) {
+		super(message);
+		this.code = code;
+		this.serviceId = serviceId;
+		this.statusCode = statusCode;
+		this.cause = cause;
+		this.failures = failures ?? [];
+	}
+}
diff --git a/borogove/AttachmentUploadErrorCode.hx b/borogove/AttachmentUploadErrorCode.hx
new file mode 100644
index 0000000..38b1aed
--- /dev/null
+++ b/borogove/AttachmentUploadErrorCode.hx
@@ -0,0 +1,10 @@
+package borogove;
+
+@:expose
+enum abstract AttachmentUploadErrorCode(String) from String to String {
+	var NoService = "no-service";
+	var InvalidSlot = "invalid-slot";
+	var HttpFailure = "http-failure";
+	var NetworkFailure = "network-failure";
+	var AllServicesFailed = "all-services-failed";
+}
diff --git a/borogove/Client.hx b/borogove/Client.hx
index b062fc6..1e47b58 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -121,7 +121,6 @@ class Client extends EventEmitter {
 	private var rosterVer: Null<String> = null;
 	private final pendingCaps: Map<String, Array<(Null<Caps>)->Chat>> = [];
 	private final brokenAvatars: Map<String, JID> = [];
-
 #if !NO_OMEMO
 	@:allow(borogove)
 	private final omemo: OMEMO;
@@ -1217,6 +1216,9 @@ class Client extends EventEmitter {
 		@param source The AttachmentSource to use
 		@param encrypt Should the data be encrypted with a fresh key?
 		@returns Promise resolving to a ChatAttachment
+		@throws AttachmentUploadError when no upload service is available or all
+			discovered services fail. Inspect `code` and, for
+			`all-services-failed`, the per-service `failures`.
 	**/
 	public function prepareAttachment(source: AttachmentSource, encrypt: Bool = true): Promise<ChatAttachment> {
 		return persistence.findServicesWithFeature(accountId(), "urn:xmpp:http:upload:0").then((services) -> {
@@ -1252,27 +1254,45 @@ class Client extends EventEmitter {
 		});
 	}
 
-	private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>): Promise<String> {
+	private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>, ?httpClient: tink.http.Client.ClientObject): Promise<String> {
 		if (services.length < 1) {
 			trace("No HTTP upload service found");
-			return Promise.reject("failed");
+			return Promise.reject(new AttachmentUploadError(
+				NoService,
+				"No HTTP Upload service was discovered"
+			));
 		}
-		final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, name, size, mime);
+		final serviceId = services[0].serviceId;
+		final httpUploadSlot = new HttpUploadSlot(serviceId, name, size, mime);
 		return new Promise((resolve, reject) -> {
 			httpUploadSlot.onFinished(() -> {
 				final slot = httpUploadSlot.getResult();
 				if (slot == null) {
-					prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject);
+					tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError(
+						InvalidSlot,
+						"HTTP Upload service returned an invalid or missing upload slot",
+						serviceId
+					), httpClient).then(resolve, reject);
 				} else {
-					tink.http.Client.fetch( slot.put, {
-						method: PUT,
-						headers: slot.putHeaders.concat([new tink.http.Header.HeaderField("Content-Length", size)]),
-						body: tink.io.Source.RealSourceTools.idealize(source, (e) -> { reject(e); throw e; })
-					}).all().handle((o) -> switch o {
-						case Success(res) if (res.header.statusCode == 201):
+					putAttachment(slot.put, slot.putHeaders, source, size, httpClient).then(statusCode -> {
+						if (statusCode == 201) {
 							resolve(slot.get);
-						default:
-							prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject);
+						} else {
+							tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError(
+								HttpFailure,
+								"HTTP Upload PUT failed with status " + statusCode,
+								serviceId,
+								statusCode
+							), httpClient).then(resolve, reject);
+						}
+					}, e -> {
+						tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError(
+								NetworkFailure,
+								"HTTP Upload PUT request failed",
+								serviceId,
+								null,
+								e
+							), httpClient).then(resolve, reject);
 					});
 				}
 			});
@@ -1280,6 +1300,56 @@ class Client extends EventEmitter {
 		});
 	}
 
+	private function putAttachment(url: String, headers: Array<tink.http.Header.HeaderField>, source: tink.io.Source.RealSource, size: Int, ?httpClient: tink.http.Client.ClientObject): Promise<Int> {
+		return new Promise((resolve, reject) -> {
+			tink.http.Client.fetch(url, {
+				method: PUT,
+				headers: headers.concat([new tink.http.Header.HeaderField("Content-Length", size)]),
+				body: tink.io.Source.RealSourceTools.idealize(source, (e) -> throw e),
+				client: httpClient == null ? null : Custom(httpClient)
+			}).handle(o -> switch o {
+				case Success(res):
+					tink.io.Source.RealSourceTools.all(res.body).handle(body -> switch body {
+						case Success(_): resolve(cast res.header.statusCode);
+						case Failure(e): reject(e);
+					});
+				case Failure(e): reject(e);
+			});
+		});
+	}
+
+	private function tryNextAttachmentService(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>, failure: AttachmentUploadError, ?httpClient: tink.http.Client.ClientObject): Promise<String> {
+		final remaining = services.slice(1);
+		if (remaining.length < 1) {
+			return Promise.reject(new AttachmentUploadError(
+				AllServicesFailed,
+				"All discovered HTTP Upload services failed",
+				null,
+				null,
+				failure.cause,
+				[failure]
+			));
+		}
+
+		return prepareAttachmentFor(source, name, size, mime, remaining, httpClient).then(result -> result, e -> {
+			final next = Std.isOfType(e, AttachmentUploadError) ? cast e : new AttachmentUploadError(
+				NetworkFailure,
+				"HTTP Upload request failed",
+				null,
+				null,
+				e
+			);
+			return Promise.reject(new AttachmentUploadError(
+				AllServicesFailed,
+				"All discovered HTTP Upload services failed",
+				null,
+				null,
+				next.cause,
+				[failure].concat(next.code == AllServicesFailed ? next.failures : [next])
+			));
+		});
+	}
+
 	/**
 		@returns array of open chats, sorted by last activity
 	**/
diff --git a/browserjs.hxml b/browserjs.hxml
index c762a2c..2f9d26d 100644
--- a/browserjs.hxml
+++ b/browserjs.hxml
@@ -11,6 +11,8 @@
 --library uuidv7
 
 borogove.Client
+borogove.AttachmentUploadError
+borogove.AttachmentUploadErrorCode
 borogove.Register
 borogove.Push
 borogove.Version
diff --git a/nodejs.hxml b/nodejs.hxml
index 58e8b97..e628e7a 100644
--- a/nodejs.hxml
+++ b/nodejs.hxml
@@ -12,6 +12,8 @@
 --library uuidv7
 
 borogove.Client
+borogove.AttachmentUploadError
+borogove.AttachmentUploadErrorCode
 borogove.Register
 borogove.Push
 borogove.Version
diff --git a/npm/index.ts b/npm/index.ts
index 19307a1..a23b8a1 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -1,4 +1,5 @@
 export {
+	borogove_AttachmentUploadErrorCode as AttachmentUploadErrorCode,
 	borogove_ChatMessageEvent as ChatMessageEvent,
 	borogove_EncryptionStatus as EncryptionStatus,
 	borogove_MessageDirection as MessageDirection,
@@ -11,6 +12,7 @@ export {
 } from "./borogove-enums.js";
 export {
     borogove_AvailableChat as AvailableChat,
+    borogove_AttachmentUploadError as AttachmentUploadError,
     borogove_AvailableChatIterator as AvailableChatIterator,
     borogove_Channel as Channel,
     borogove_Chat as Chat,
diff --git a/test/FakeHttpClient.hx b/test/FakeHttpClient.hx
new file mode 100644
index 0000000..604c0ec
--- /dev/null
+++ b/test/FakeHttpClient.hx
@@ -0,0 +1,38 @@
+package test;
+
+import tink.http.Client.ClientObject;
+import tink.http.Request.OutgoingRequest;
+import tink.http.Response.IncomingResponse;
+import tink.core.Promise;
+
+class FakeHttpClient implements ClientObject {
+	public final requests:Array<OutgoingRequest> = [];
+	final handler:OutgoingRequest->Promise<IncomingResponse>;
+
+	public function new(handler:OutgoingRequest->Promise<IncomingResponse>) {
+		this.handler = handler;
+	}
+
+	public function request(request:OutgoingRequest):Promise<IncomingResponse> {
+		requests.push(request);
+		return handler(request);
+	}
+
+	public static function response(statusCode:Int, ?body:String):Promise<IncomingResponse> {
+		return Promise.resolve(new IncomingResponse(
+			new tink.http.Response.ResponseHeader(statusCode),
+			(body ?? "" : tink.io.Source.RealSource)
+		));
+	}
+
+	public static function bodyFailure(error:tink.core.Error):Promise<IncomingResponse> {
+		return Promise.resolve(new IncomingResponse(
+			new tink.http.Response.ResponseHeader(201),
+			tink.io.Source.ofError(error)
+		));
+	}
+
+	public static function requestFailure(error:tink.core.Error):Promise<IncomingResponse> {
+		return Promise.reject(error);
+	}
+}
diff --git a/test/TestClient.hx b/test/TestClient.hx
index ec56090..ab07140 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -18,6 +18,11 @@ import borogove.Stanza;
 import borogove.Status;
 import borogove.persistence.Dummy;
 import borogove.Chat.OutgoingE2EEPreference;
+import borogove.AttachmentUploadError;
+import borogove.AttachmentUploadErrorCode;
+#if !NO_OMEMO
+import borogove.XEP0454.put;
+#end
 
 using Lambda;
 
@@ -94,6 +99,130 @@ class TestClient extends utest.Test {
 		Assert.equals("test@example.com", client.accountId());
 	}
 
+	@:timeout(3000)
+	public function testPrepareAttachmentNoUploadService(async: Async) {
+		final client = new Client("test@example.com", new Dummy());
+		client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [])
+			.then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> {
+				final error: AttachmentUploadError = cast e;
+				Assert.equals(NoService, error.code);
+				async.done();
+				return null;
+			});
+	}
+
+	@:timeout(3000)
+	public function testPrepareAttachmentRejectsMalformedSlot(async: Async) {
+		final client = new Client("test@example.com", new Dummy());
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) {
+				client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") }));
+			}
+			return EventHandled;
+		});
+		client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }])
+			.then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> {
+				final error: AttachmentUploadError = cast e;
+				Assert.equals(AllServicesFailed, error.code);
+				Assert.equals(InvalidSlot, error.failures[0].code);
+				async.done();
+				return null;
+			});
+	}
+
+	@:timeout(3000)
+	public function testPrepareAttachmentFallsBackAfterHttpFailure(async: Async) {
+		var putCount = 0;
+		final http = new FakeHttpClient(_ -> FakeHttpClient.response(++putCount == 1 ? 500 : 201));
+		final client = new Client("test@example.com", new Dummy());
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			final request = stanza.getChild("request", "urn:xmpp:http:upload:0");
+			if (request != null) {
+				final service = stanza.attr.get("to");
+				client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") })
+					.tag("slot", { xmlns: "urn:xmpp:http:upload:0" })
+						.tag("put", { url: "http://" + service + "/put" }).up()
+						.tag("get", { url: "https://" + service + "/get" }).up()
+					.up());
+			}
+			return EventHandled;
+		});
+		client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "first" }, { serviceId: "second" }], http)
+			.then(url -> {
+				Assert.equals("https://second/get", url);
+				async.done();
+				return null;
+			}, e -> {
+				Assert.fail(Std.string(e));
+				async.done();
+				return null;
+			});
+	}
+
+	@:timeout(3000)
+	public function testPrepareAttachmentReportsHttpFailure(async: Async) {
+		final http = new FakeHttpClient(_ -> FakeHttpClient.response(503));
+		final client = new Client("test@example.com", new Dummy());
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) {
+				client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") })
+					.tag("slot", { xmlns: "urn:xmpp:http:upload:0" })
+						.tag("put", { url: "http://upload.example.com/put" }).up()
+						.tag("get", { url: "https://upload.example.com/get" }).up()
+					.up());
+			}
+			return EventHandled;
+		});
+		client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }], http)
+			.then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> {
+				final error: AttachmentUploadError = cast e;
+				Assert.equals(AllServicesFailed, error.code);
+				Assert.equals(HttpFailure, error.failures[0].code);
+				Assert.equals(503, error.failures[0].statusCode);
+				async.done();
+				return null;
+			});
+	}
+
+	@:timeout(3000)
+	public function testPrepareAttachmentReportsNetworkFailure(async: Async) {
+		final bodyFailure = new tink.core.Error("body failed");
+		final http = new FakeHttpClient(_ -> FakeHttpClient.bodyFailure(bodyFailure));
+		final client = new Client("test@example.com", new Dummy());
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) {
+				client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") })
+					.tag("slot", { xmlns: "urn:xmpp:http:upload:0" })
+						.tag("put", { url: "http://upload.example.com/put" }).up()
+						.tag("get", { url: "https://upload.example.com/get" }).up()
+					.up());
+			}
+			return EventHandled;
+		});
+		client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }], http)
+			.then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> {
+				final error: AttachmentUploadError = cast e;
+				Assert.equals(AllServicesFailed, error.code);
+				Assert.equals(NetworkFailure, error.failures[0].code);
+				Assert.equals(bodyFailure, error.failures[0].cause);
+				async.done();
+				return null;
+			});
+	}
+
+	#if !NO_OMEMO
+	@:timeout(3000)
+	public function testEncryptedAttachmentPreservesUploadError(async: Async) {
+		final uploadError = new AttachmentUploadError(HttpFailure, "upload failed", "upload.example.com", 503);
+		put(("body" : tink.io.Source.RealSource), (_, _) -> Promise.reject(uploadError))
+			.then(_ -> { Assert.fail("expected encryption upload to fail"); return null; }, e -> {
+				Assert.isTrue(e == uploadError);
+				async.done();
+				return null;
+			});
+	}
+	#end
+
 	public function testModerateMessage(async: Async) {
 		final persistence = new MessageMockPersistence();
 		final client = new Client("test@example.com", persistence);