Skip to content

api: Include stack trace when reporting MalformedServerResponseException #1374

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

lakshya1goel
Copy link
Contributor

@lakshya1goel lakshya1goel commented Feb 23, 2025

Fixes: #1083

Changes:

  • Add causeStackTrace field to MalformedServerResponseException
  • Include stack trace in the exception's toString() output
  • Add test to verify stack trace is included in error messages

Testing:

  • Added unit test that verifies stack trace is included when a type mismatch occurs
  • Manually tested with registerQueue to confirm stack trace shows in error dialog

Screenshot of error

image

@gnprice
Copy link
Member

gnprice commented Feb 24, 2025

It doesn't look like this new parameter causeStackTrace ever gets passed to the constructor. So it doesn't actually do anything in the live app.

Please test your change end to end in the app, and revise it so it solves the problem described in the issue:

When a request to the server fails, we typically show an error to the user. (The main gap in that is #890, which we should fix.) The details of the error can be useful, particularly if the user takes a screenshot of them to include in reporting the issue to us.

If the cause of the error is that the server's response doesn't match our expectations — a MalformedServerResponseException — then a key thing we want to know is where in the schema the mismatch occurred: what field of what type of object. […] So we should show the stack trace when reporting a MalformedServerResponseException.

@lakshya1goel
Copy link
Contributor Author

Hi @PIG208, whenever you get a chance, could you please take a look at my PR for an initial review? Thanks!

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for working on this! Left some comments.

wrongType['properties']! as Map<String, dynamic>;
fail('Should have thrown');
} catch (e, stackTrace) {
final exception = MalformedServerResponseException(
Copy link
Member

Choose a reason for hiding this comment

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

A more end-to-end test will be more valuable. MalformedServerResponseException already has a bunch of tests in test/api/core_test.dart.

Copy link
Member

Choose a reason for hiding this comment

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

We should then leave a comment similar to this one above:

// NetworkException.toString: see "API network errors" test in core_test.dart

to indicate that the tests live elsewhere.

Copy link
Member

Choose a reason for hiding this comment

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

Bumping this. I meant that we should test MalformedServerResponseException.toString in test/api/core_test.dart, removing the test in test/api/exception_test.dart, and adding the comment in test/api/exception_test.dart indicating that the test for MalformedServerResponseException.toString lives in core_test.dart.

The other comment that you moved in the new revision was meant to be at where it was earlier, since it is a placeholder indicating that a test would be there when it isn't; it's not a description of the test.


MalformedServerResponseException({
required super.routeName,
required super.httpStatus,
required super.data,
this.causeException,
this.causeStackTrace
}) : super(message: causeException == null
? GlobalLocalizations.zulipLocalizations
.errorMalformedResponse(httpStatus)
: GlobalLocalizations.zulipLocalizations
.errorMalformedResponseWithCause(
httpStatus, causeException.toString()));
Copy link
Member

Choose a reason for hiding this comment

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

We already stringify causeException and make it a part of message. Including it again in the toString implementation will repeat that in the error message. This is what I got when printing out the error from the test:

MalformedServerResponseException: 200 registerQueue: Server gave malformed response; HTTP status 200; type 'String' is not a subtype of type 'Map<String, dynamic>' in type cast
Caused by: type 'String' is not a subtype of type 'Map<String, dynamic>' in type cast
#0      main.<anonymous closure> (file:///home/zixuan/zulip-stuff/zulip-flutter/test/api/exception_test.dart:49:32)
#1      Declarer.test.<anonymous closure>.<anonymous closure> (package:test_api/src/backend/declarer.dart:229:19)
<asynchronous suspension>
#2      Declarer.test.<anonymous closure> (package:test_api/src/backend/declarer.dart:227:7)
<asynchronous suspension>
#3      Invoker._waitForOutstandingCallbacks.<anonymous closure> (package:test_api/src/backend/invoker.dart:258:9)
<asynchronous suspension>
true

sb.write(" $routeName");
sb.write(": $message");
if (causeException != null) {
sb.write('\nCaused by: $causeException');
Copy link
Member

Choose a reason for hiding this comment

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

New strings added should be localized. We already have errorMalformedResponseWithCause and errorMalformedResponse, so it might be a good idea to reuse them/add one.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I see that you added "Caused by" back. It's an English phrase that should be translated.

sb.write(": $message");
if (causeException != null) {
sb.write('\nCaused by: $causeException');
if (causeStackTrace != null) {
Copy link
Member

Choose a reason for hiding this comment

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

Looks like it would be a developer error if causeException is present but causeStackTrace is not. Let's have an assertion in the constructor to validate that.

@lakshya1goel
Copy link
Contributor Author

Hi @PIG208, thanks for the above reviews. I have pushed the revision. PTAL.

@@ -552,6 +552,24 @@
"details": {"type": "String", "example": "type 'Null' is not a subtype of type 'String' in type cast"}
}
},
"errorMalformedResponseFormat": "{className}: {httpStatus} {routeName}: {message}",
"@errorMalformedResponseFormat": {
"description": "Format for error messages from malformed server responses. The message parameter is already localized.",
Copy link
Member

Choose a reason for hiding this comment

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

nit: the audience of the string description is mainly the translators; so there is nothing actionable for them knowing that message is localized. Similarly, we can leave out "format" in both the name of the string and its description.

How about naming it "errorMalformedServerResponse"? For rewriting the description, you can find soem examples with:

git grep -hEA2 'error.+\",' assets/l10n/app_en.arb

You can check out git grep --help for what each of these options mean.

Comment on lines 569 to 572
"errorMalformedServerResponseExceptionAssertMessage": "If causeException is provided, causeStackTrace must also be provided",
"@errorMalformedServerResponseExceptionAssertMessage": {
"description": "Developer error message when creating a MalformedServerResponseException with incomplete error details"
},
Copy link
Member

Choose a reason for hiding this comment

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

Because assertions are only run in debug builds, messages from them are usually only visible to the developer. We can skip translating those strings.

Comment on lines 565 to 568
"errorMalformedServerResponseExceptionName": "MalformedServerResponseException",
"@errorMalformedServerResponseExceptionName": {
"description": "Name of the error type for malformed server responses"
},
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, it doesn't seem that a class name that is only meaningful to our codebase can be translated.

Comment on lines 171 to 176
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
return zulipLocalizations.errorMalformedResponseFormat(
zulipLocalizations.errorMalformedServerResponseExceptionName,
httpStatus,
routeName,
message);
Copy link
Member

Choose a reason for hiding this comment

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

Looks like the stack trace is not used in this revision. We should include it in the error message.

I think the structure we had in the previous revision is good. #1374 (comment) was for the untranslated "Cause by". Since we have removed it in favor of errorMalformedResponseWithCause, the remaining parts of the error message probably don't need translations.

@PIG208
Copy link
Member

PIG208 commented Mar 10, 2025

Thanks for the update! Left some comments.

@lakshya1goel
Copy link
Contributor Author

Hi @PIG208, thanks for the review. I have pushed the revision. PTAL!

@lakshya1goel
Copy link
Contributor Author

Hi @PIG208 , whenever you get some time, please take a look at the PR. Thanks!

@chrisbobbe chrisbobbe added the maintainer review PR ready for review by Zulip maintainers label Mar 21, 2025
Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for the update! Left some more comments

Comment on lines 160 to 161
}) : assert(causeException == null || causeStackTrace != null,
'If causeException is provided, causeStackTrace must also be provided'),
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't cover the case when causeException is missing and causeStackTrace is given. How about something like (causeException == null && causeStackTrace == null) || (causeException != null && causeStackTrace != null)?

sb.write(" $routeName");
sb.write(": $message");
if (causeException != null) {
sb.write('\nCaused by: $causeException');
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I see that you added "Caused by" back. It's an English phrase that should be translated.

Comment on lines 169 to 179
@override
String toString() {
final StringBuffer sb = StringBuffer('MalformedServerResponseException');
sb.write(" $routeName");
sb.write(": $message");
if (causeException != null) {
sb.write('\nCaused by: $causeException');
sb.write('\n$causeStackTrace');
}
return sb.toString();
}
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I see that you added "Caused by" back. It's an English phrase that should be translated. However, maybe we don't need to override toString at all? ZulipApiException overrides it because it has multiple checks on fields like httpStatus, code, and data. But for MalformedServerResponseException, we are only checking causeException.

The original code seems capable of handling this:

  MalformedServerResponseException({
    required super.routeName,
    required super.httpStatus,
    required super.data,
    this.causeException,
  }) : super(message: causeException == null
         ? GlobalLocalizations.zulipLocalizations
            .errorMalformedResponse(httpStatus)
         : GlobalLocalizations.zulipLocalizations
            .errorMalformedResponseWithCause(
              httpStatus, causeException.toString()));

We just need to add the stack trace to the case where causeException is non-null, and have the assertions here to ensure that both of these fields are non-null or null at the same time.

try {
throw FormatException("Expected field 'stream_id' to be an integer, but got string 'abc123'");
} catch (e, st) {
final exception = MalformedServerResponseException(
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, it would be ideal for the MalformedServerResponseException to be thrown by non-test code, as this is the point of having this test in core_test.dart.

I think an existing test, "malformed API success responses: exception preserves details", has all the setup needed to test this correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing. So should I update the exiting test only to check for the causeStackTrace as well or need to add a separate test for it?

Updated test can be:

test('malformed API success responses: exception preserves details', () async {
    int distinctivelyNamedFromJson(Map<String, dynamic> json) {
      throw DistinctiveError("something is wrong");
    }

    try {
      await tryRequest(json: {}, fromJson: distinctivelyNamedFromJson);
      assert(false);
    } catch (e, st) {
      check(e).isA<MalformedServerResponseException>()
        ..causeException.isA<DistinctiveError>()
        ..message.contains("something is wrong")
        ..has((it) => it.causeStackTrace, "causeStackTrace").isNotNull()
        ..has((it) => it.causeStackTrace.toString(), "causeStackTrace.toString()")
          .contains("distinctivelyNamedFromJson");
      check(st.toString()).contains("distinctivelyNamedFromJson");
    }
  });

Copy link
Member

Choose a reason for hiding this comment

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

Updating the existing test sounds good to me. I would add causeStackTrace to the …Checks extension of the error, to avoid inlining the has' here.

@lakshya1goel lakshya1goel force-pushed the issue1083 branch 2 times, most recently from 7e205d4 to 56e39b8 Compare March 25, 2025 17:46
@lakshya1goel lakshya1goel requested a review from PIG208 March 25, 2025 18:17
@lakshya1goel
Copy link
Contributor Author

Hi @PIG208, I have pushed the revision PTAL, Thanks!

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for the update! Could you also include a screenshot of what an error message would look like with the next revision? Left some comments.

}) : assert((causeException == null && causeStackTrace == null) ||
(causeException != null && causeStackTrace != null),
'causeException and causeStackTrace must either both be null or both be non-null'),
super(message: causeException == null
? GlobalLocalizations.zulipLocalizations
.errorMalformedResponse(httpStatus)
: GlobalLocalizations.zulipLocalizations
Copy link
Member

Choose a reason for hiding this comment

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

Looks like this still needs to add the stack trace to the error message below.


MalformedServerResponseException({
required super.routeName,
required super.httpStatus,
required super.data,
this.causeException,
}) : super(message: causeException == null
this.causeStackTrace
Copy link
Member

Choose a reason for hiding this comment

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

nit: trailing comma

@@ -425,7 +425,8 @@ void main() {
} catch (e, st) {
check(e).isA<MalformedServerResponseException>()
..causeException.isA<DistinctiveError>()
..message.contains("something is wrong");
..message.contains("something is wrong")
..causeStackTrace.toString().contains("distinctivelyNamedFromJson");
Copy link
Member

Choose a reason for hiding this comment

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

We should check that the stack trace is a part of the error message, so it is visible in the error shown to the user.

Copy link
Member

Choose a reason for hiding this comment

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

This looks better now, one nit is that we want to make sure that the string to match against the stack trace is uniquely present in the stack trace. I think
"distinctivelyNamedFromJson" is a bit dubious identifier since the error message might reasonable contain it.

How about just the first line of the actual stack trace without the path, i.e., "#0 main..distinctivelyNamedFromJson"?

Copy link
Member

Choose a reason for hiding this comment

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

Calling toString() on a Subject also causes this to not fail when the expected substring is not found, because we intended to call Subject.contains, but this actually calls String.contains, which just returns a bool and does not throw.

@lakshya1goel
Copy link
Contributor Author

Hi @PIG208, I have pushed the revision as well as updates the PR description with the screenshot of error. PTAL, thanks!

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for the update! I think the test still needs some tweaks, but it looks a lot closer now.

One other note: I think I wasn't clear in my previous #1374 (review) that I was referring to taking a screenshot of the error the user might see while using the app.

@@ -28,4 +28,5 @@ extension Server5xxExceptionChecks on Subject<Server5xxException> {

extension MalformedServerResponseExceptionChecks on Subject<MalformedServerResponseException> {
Subject<Object?> get causeException => has((e) => e.causeException, 'causeException');
Subject<String> get causeStackTrace => has((e) => e.causeStackTrace.toString(), 'causeStackTrace');
Copy link
Member

Choose a reason for hiding this comment

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

The type should match the actual property:

Suggested change
Subject<String> get causeStackTrace => has((e) => e.causeStackTrace.toString(), 'causeStackTrace');
Subject<StackTrace?> get causeStackTrace => has((e) => e.causeStackTrace, 'causeStackTrace');

Copy link
Member

Choose a reason for hiding this comment

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

And then, we can have StackTraceChecks next to ErrorChecks in test/stdlib_checks.dart that offers asString on Subject<StackTrace>.

@@ -425,7 +425,8 @@ void main() {
} catch (e, st) {
check(e).isA<MalformedServerResponseException>()
..causeException.isA<DistinctiveError>()
..message.contains("something is wrong");
..message.contains("something is wrong")
..causeStackTrace.toString().contains("distinctivelyNamedFromJson");
Copy link
Member

Choose a reason for hiding this comment

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

This looks better now, one nit is that we want to make sure that the string to match against the stack trace is uniquely present in the stack trace. I think
"distinctivelyNamedFromJson" is a bit dubious identifier since the error message might reasonable contain it.

How about just the first line of the actual stack trace without the path, i.e., "#0 main..distinctivelyNamedFromJson"?

@@ -425,7 +425,8 @@ void main() {
} catch (e, st) {
check(e).isA<MalformedServerResponseException>()
..causeException.isA<DistinctiveError>()
..message.contains("something is wrong");
..message.contains("something is wrong")
..causeStackTrace.toString().contains("distinctivelyNamedFromJson");
Copy link
Member

Choose a reason for hiding this comment

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

Calling toString() on a Subject also causes this to not fail when the expected substring is not found, because we intended to call Subject.contains, but this actually calls String.contains, which just returns a bool and does not throw.

@lakshya1goel
Copy link
Contributor Author

One other note: I think I wasn't clear in my previous #1374 (review) that I was referring to taking a screenshot of the error the user might see while using the app.

Should I add a snackbar or dailogbox in the app to show error to the user on the app screen?

@lakshya1goel lakshya1goel requested a review from PIG208 April 15, 2025 06:26
@PIG208
Copy link
Member

PIG208 commented Apr 15, 2025

I think we shouldn't need to add a snack bar/dialog, since the error should have been handled from the polling loop in UpdateMachine.

Such an error might look like this:

image

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for the update! Marking this for Greg's review.

@@ -30,6 +30,10 @@ extension ErrorChecks on Subject<Error> {
Subject<String> get asString => has((x) => x.toString(), 'toString'); // TODO(checks): what's a good convention for this?
}

extension StackTraceChecks on Subject<StackTrace> {
Subject<String> get asString => has((x) => x.toString(), 'toString');
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
Subject<String> get asString => has((x) => x.toString(), 'toString');
Subject<String> get asString => has((x) => x.toString(), 'toString'); // TODO(checks): what's a good convention for this?

(removed trailing space and copied the comment from the other asString check)

@PIG208 PIG208 added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Apr 15, 2025
@PIG208 PIG208 assigned gnprice and unassigned PIG208 Apr 15, 2025
@PIG208 PIG208 requested a review from gnprice April 15, 2025 20:38
@gnprice
Copy link
Member

gnprice commented Apr 15, 2025

As @PIG208 said above, I think we shouldn't need to add any new snack bar or dialog in order for the error to get shown to the user — the existing error dialog should do it.

But as the issue #1083 says, and as I emphasized in #1374 (comment) at the top of this thread, the point of the issue is about what information we show to the user.

So, does this PR cause the stack trace to get shown to the user when they hit one of these errors? Please post a screenshot of what that looks like.

(The PR description currently has a screenshot of something shown in your terminal, as a developer. That isn't informative about what the user sees. If it were relevant information, then copy-paste into a code block would be a clearer way to present it than a screenshot.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Include stack trace when reporting MalformedServerResponseException
4 participants