The only way to catch an exception is to have a callback on the promise that generated the exception.
In the explained scenario, the contractA.callback() shouldn't crash. You need to construct the contract carefully enough to avoid failing on the callback. Most of the time it's possible to do, since you control the input to the callback and the amount gas attached. If the callback fails, it's similar to having an exception within an exception handling code.
Also note, that you can make sure the callback is scheduled properly with the enough gas attached in contractA.run(). If it's not the case and for example you don't have enough gas attached to run, the scheduling of callback and other promise will fail and the entire state from run changes is rolled back.
But once run completes, the state changes from run are committed and callback has to be carefully processed.
We have a few places in lockup contract where the callback is allowed to fail: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/owner_callbacks.rs#L7-L24
And also most of the places where the callback doesn't fail: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/owner_callbacks.rs#L28-L61
To point out there are some situation where the contract doesn't want to rely on the stability of other contracts, e.g. when the flow is A --> B --> A --> B. In this case B can't attach the callback to the resource given to A. For these scenarios we were discussing a possibility of adding a specific construct that is an atomic and has a resolving callback once it's dropped. We called it Safe: https://github.com/nearprotocol/NEPs/pull/26
EDIT
What if contractB.run fails and I will like to update the state in contractA to rollback changes from contractA.run?
In this case contractA.callback() is still called, but it has PromiseResult::Failed for its dependency contractB.run.
So callback() can modify the state of contractA to revert changes.
For example, a callback from the lockup contract implementation to handle withdrawal from the staking pool contract: https://github.com/near/core-contracts/blob/6fb13584d5c9eb1b372cfd80cd18f4a4ba8d15b6/lockup/src/foundation_callbacks.rs#L143-L185
If we adapt names to match the example:
The lockup contract (contractA) tries to withdraws funds (run()) from the staking pool (contractB), but the funds might still be locked due to recent unstaking, so the withdrawal fails (contractB.run() fails).
The callback is called (contractA.callback()) and it checks the success of the promise (of contractB.run). Since withdrawal failed, callback reverts the state back to the original (reverts the status).
Actually, it's slightly more complicated because the actual sequence is A.withdraw_all -> B.get_amount -> A.on_amount_for_withdraw -> B.withdraw(amount) -> A.on_withdraw