Skip to main content
The Portal Android SDK uses a structured 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..599 range are reported as null for the same reason.
  • The request returned 200 OK but 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.
Treat 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:
To change the limits, construct a 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.
The read timeout applies per request, not per operation. A multi-step flow such as sendAsset, backupWallet or recoverWallet makes several requests and can legitimately run longer than 60 seconds end to end without any single request timing out.

Absent shares versus unreadable shares

As of 9.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.
Treat a null result as “this device has no usable share” rather than strictly “no share exists”. Code that relied on the throw to detect “no wallet on this device” will now take the success path with a null value instead of entering its catch block, so check the result for null explicitly.
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 generic Error, 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(...) and Exception(...) throws have been replaced with specific PortalException subclasses.
  • Flat exception classes (SendAssetArgumentError, PortalHttpUnauthorizedException, PasskeyNotSupportedException, PasskeyStorageNotConfiguredException, MpcError, InvalidWalletStateError, StorageAuthError) now extend their corresponding PortalException subcategory. Your existing catch blocks will still work, but you should migrate to the new types.

Before and after

Deprecated classes mapping