Skip to content

Dont use internal synchronicity UserCodeException type #3168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from

Conversation

freider
Copy link
Contributor

@freider freider commented May 20, 2025

This turned out to be a bit of a rabbit hole.

  • The client has abused the fact that synchronicity unwraps synchronicity.UserCodeExceptions and wrapped any exceptions returned by containers
  • I believe the original intent of this was to piggy back off synchronicity's use of this to exclude certain internal frames in tracebacks
  • More recently, we've also started using it for client-side retries, since it has been a way for us to disambiguate internal errors from errors in user remote containers (things that should be retried)
  • Synchronicity only unwraps these exceptions when they are raised, so an unfortunate side effect has been that map(return_exceptions=True) has returned actual synchronicity.UserCodeException objects instead of the actual exception (!)

This PR removes the usage of this internal synchronicity exception type in favor of an explicit modal-specific exception wrapper type, _ContainerException (open to other names). This means we also need to explicitly unwrap these whenever exceptions can be returned to users.

Unfortunately, this is is a breaking change for .map(return_exceptions=True) in that it will no longer wrap the user exceptions... But I believe that has never actually been the intent of the functionality, and it's quite ugly that it has been returning a synchronicity exception type (!).

Closes CLI-419


Compatibility checklist

Check these boxes or delete any item (or this section) if not relevant for this PR.

  • Client+Server: this change is compatible with old servers
  • Client forward compatibility: this change ensures client can accept data intended for later versions of itself

Note on protobuf: protobuf message changes in one place may have impact to
multiple entities (client, server, worker, database). See points above.


Release checklist

If you intend for this commit to trigger a full release to PyPI, please ensure that the following steps have been taken:

  • Version file (modal_version/__init__.py) has been updated with the next logical version
  • Changelog has been cleaned up and given an appropriate subhead

Changelog

raise exc.unwrap()


async def _process_result_wrap_exceptions(result: api_pb2.GenericResult, data_format: int, stub, client=None):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is to use a boolean flag to wrap exceptions, but that feels a bit more smelly to me

@@ -357,6 +356,35 @@ async def test_function_future_async(client, servicer):
assert future.object_id not in servicer.cleared_function_calls # keep results around a bit longer for futures


def test_function_spawn_exception(client, servicer):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of scary that we didn't have any tests for spawn or generator calls with exceptions...

@@ -533,7 +561,7 @@ def test_map_exceptions(client, servicer):

res = list(custom_function_modal.map(range(6), return_exceptions=True))
assert res[:4] == [0, 1, 4, 9] and res[5] == 25
assert type(res[4]) is UserCodeException and "bad" in str(res[4])
assert type(res[4]) is CustomException and "bad" in str(res[4])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking change!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is breaking, I'll say this is more of a bug fix.

or not ctx.sync_client_retries_enabled
):
return await self._get_single_output()
try:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only place where we actually "need" the exception wrapping (in order to detect retryable errors), so it's explicitly using _get_single_output_wrap_exceptions and then unwrapping it in an outer try/except

@freider freider requested review from mwaskom and rculbertson May 20, 2025 09:21
@freider
Copy link
Contributor Author

freider commented May 20, 2025

Related thing I noticed - run_generator currently calls run_function, but run_function is unnecessarily complex with retry logic that is never used for generators so maybe we could have it just call _get_single_output instead? wdyt @rculbertson ?

@rculbertson
Copy link
Contributor

Nice! Just curious, how did you start down this rabbit hole? :) Was this causing a user issue?

Yea, calling _get_single_output for generators seems good to me.

Comment on lines +303 to +304
except _ContainerException as exc:
raise exc.unwrap()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: In an effort to try to reduce the amount of indenting can the try, except be a decorator? Something like this:

def _unwrap_container_exception(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except _ContainerException as exc:
            raise exc.unwrap()
    return wrapper

@_unwrap_container_exception
async def run_function(self) -> Any:
    ...

@@ -22,6 +22,19 @@ class Error(Exception):
"""


class _ContainerException(Exception):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer this name a little more:

Suggested change
class _ContainerException(Exception):
class _WrappedException(Exception):

At a glance, I thought "Container" meant "in the runtime container".

@@ -533,7 +561,7 @@ def test_map_exceptions(client, servicer):

res = list(custom_function_modal.map(range(6), return_exceptions=True))
assert res[:4] == [0, 1, 4, 9] and res[5] == 25
assert type(res[4]) is UserCodeException and "bad" in str(res[4])
assert type(res[4]) is CustomException and "bad" in str(res[4])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is breaking, I'll say this is more of a bug fix.

@freider
Copy link
Contributor Author

freider commented Jun 16, 2025

Superseded by #3226

@freider freider closed this Jun 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants