PortalException hierarchy so you can catch errors at the granularity you need — from individual error cases up to broad categories. Errors the SDK raises itself are subclasses of PortalException, which itself extends Exception. Network and transport failures — including timeouts — propagate as the underlying IOException, and argument or state checks can throw IllegalArgumentException or IllegalStateException, so don’t rely on catch (e: PortalException) alone.
Exception hierarchy
PortalException is a sealed class with ten category-level sealed subclasses:
Catching exceptions
Because the hierarchy is sealed, you can catch at any level of specificity.Catch a specific exception
Catch an entire category
Catch any Portal exception
Handle errors from Result-returning methods
Some methods like sendAsset return a Result instead of throwing. Use onFailure or exceptionOrNull() to inspect the error:
Branch on the HTTP status code
PortalException.Api.HttpRequestFailed exposes the status code that produced the failure, so you can separate a transient server-side error from a terminal one without matching on the message string.
HttpRequestFailed only covers status codes that aren’t already routed to a more specific exception: 4xx codes of 402 and above, every 5xx, and unrecognized codes. One exception: when the Enclave MPC API responds 409 or 422 with a Portal error id in the body, the SDK throws PortalException.Mpc.MpcResultError with that id instead (starting from SDK version 10.1.0). A 400 throws PortalException.Api.HttpBadRequest and a 401 throws PortalException.Api.HttpUnauthorized, both of which are siblings of HttpRequestFailed rather than subclasses. statusCode is therefore never 400 or 401 — catch those two types directly.
The whole 2xx range is a success. A 201 or 202 returns its body like a 200 does, and a 204 returns an empty string.
Earlier versions treated only
200 and 204 as successes. Any other 2xx — a 201 from a
resource-creating endpoint, say — threw HttpRequestFailed and discarded the body it had just
returned. If you have a catch that treats HttpRequestFailed with a 2xx statusCode as an
expected outcome, it is now unreachable.statusCode is null in two situations, and neither one means “unknown error”:
- No HTTP response was received. A socket timeout, a DNS failure, or a malformed status line never produces a status code. Codes outside the
100..599range are reported asnullfor the same reason. - The request returned
200 OKbut the body carried an error envelope. The failure is real, but the HTTP status was a success, so there is no error code to report.
null as “there is no status code to branch on” and fall through to your default handling:
statusCode was added in 9.1.0. On earlier versions the only way to identify the status code was to parse it out of message, which is formatted as "<statusCode> - <responseBody>".Request timeouts
Every HTTP request the SDK makes is bounded. A host that accepts the connection and then never answers fails the call instead of suspending it.
A timeout surfaces as a
java.net.SocketTimeoutException, propagated unwrapped like every other transport failure — it is not a PortalException, so a catch (e: PortalException) will not see it, and a Result-returning method carries the raw exception in its failure. Catch IOException alongside the Portal types:
PortalRequests and pass it to Portal:
Timeouts were added in
10.0.0. Before that both defaulted to HttpURLConnection’s 0, meaning infinite: a request to a host that accepted the connection and stopped responding never returned, and never threw. If you added your own watchdog around Portal calls to work around that, you can now rely on the SDK’s own limits — though note that a withTimeout around a suspending Portal call still will not cancel the underlying socket read, which is why the bound has to live in the transport.Absent shares versus unreadable shares
As of9.1.0, having no share on the device is no longer an error. getShares() on a Keychain instance returns null in that case; before 9.1.0 it threw IllegalArgumentException("No user share found on device storage").
getShares() reads through a fallback chain: the current shares key first, then three legacy key formats kept for backward compatibility. An entry that can’t be parsed doesn’t fail the call — it moves on to the next format in the chain. So an unreadable entry under the current shares key resolves to null too, as long as no legacy entry is readable either.
The
"Unable to parse share from device storage" message is reserved for the last link in that chain — the misspelled deprecated key from an older SDK version — so in practice you’ll only see it on a device mid-migration. A parse failure elsewhere in the chain surfaces as whatever the JSON parser threw, not as an IllegalArgumentException.
This is what makes portal.isWalletOnDeviceOrThrow() return false rather than throw when the user has no usable wallet on the current device. See manage wallet lifecycle states for the migration.
Exception reference
MPC (PortalException.Mpc)
Storage (PortalException.Storage)
Wallet (PortalException.Wallet)
Transaction (PortalException.Transaction)
API (PortalException.Api)
A
401 on a request that carried Portal’s bearer token also retires the credential behind the
Portal instance that made the request, and fires that instance’s
onSessionInvalidated() listeners. This changes
nothing for a Portal built from a Client API Key or a
Client Session Token: those credentials are
custodian-owned, retiring one is a no-op, and the listener is never fired for them — so keep using
HttpUnauthorized as your signal to refresh a CST. It matters under
Client Auth, where the credential is a session that is now spent.Carrying the bearer is the whole trigger — the host is not part of the test. A Portal API reached
through your own apiHost override receives the token like any other, so its 401s retire the
credential too, rather than leaving the SDK replaying a token the backend has already rejected.A 401 from a dApp gateway or a third-party RPC never triggers this, because the SDK sends those
requests without a Portal credential in the first place: portal.request(...) attaches the bearer
only to Portal’s own RPC hosts. However such a gateway answers, it cannot retire your credential.Credentials (PortalException.Credentials)
Raised when a Portal instance cannot turn its credential into a bearer token. See
Client Auth for how to recover from each reason.
PortalCredentialError.reason is one of:
A token the MPC binary rejects does not surface as a
PortalCredentialError. The binary makes
its own HTTP calls, so the SDK reads its AUTH_FAILED result and throws
PortalException.Mpc.MpcResultError as it always has — while still retiring the credential and
firing onSessionInvalidated(). Existing catch blocks around createWallet, backupWallet,
recoverWallet, and signing are unaffected.Auth (PortalException.Auth)
Raised by PortalAuth during a Client Auth sign-in. Once a session exists,
failures to turn it into a bearer token are Credentials errors instead.
Transport failures are not remapped into
PortalException.Auth. A grant the backend refuses —
already used, expired, or issued for another environment — surfaces as
PortalException.Api.HttpBadRequest carrying the backend’s message.Connect (PortalException.Connect)
Provider (PortalException.Provider)
Blockchain (PortalException.Blockchain)
Migration from previous versions
In previous versions of the Android SDK, errors were thrown as genericError, Exception, or flat PortalException subclasses like SendAssetArgumentError and PortalHttpUnauthorizedException. These legacy classes are now deprecated and will be removed in a future major version.
What changed
- All generic
Error(...)andException(...)throws have been replaced with specificPortalExceptionsubclasses. - Flat exception classes (
SendAssetArgumentError,PortalHttpUnauthorizedException,PasskeyNotSupportedException,PasskeyStorageNotConfiguredException,MpcError,InvalidWalletStateError,StorageAuthError) now extend their correspondingPortalExceptionsubcategory. Your existingcatchblocks will still work, but you should migrate to the new types.