diff --git a/CLAUDE.md b/CLAUDE.md index c254a056..19ff8729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests. This file is a repo overview; the style guide is the rulebook. +> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests, and [`EFFECTIVE_DART_DOC.md`](EFFECTIVE_DART_DOC.md) — a vendored copy of Effective Dart's documentation guide — before writing any dartdoc; the style guide wins where they disagree. This file is a repo overview; the style guide is the rulebook. ## Project Overview diff --git a/EFFECTIVE_DART_DOC.md b/EFFECTIVE_DART_DOC.md new file mode 100644 index 00000000..f2f08d93 --- /dev/null +++ b/EFFECTIVE_DART_DOC.md @@ -0,0 +1,627 @@ +# Effective Dart: Documentation + +> Vendored from [dart.dev/effective-dart/documentation](https://dart.dev/effective-dart/documentation) +> ([source](https://github.com/dart-lang/site-www/blob/main/src/content/effective-dart/documentation.md), +> CC BY 4.0) so it is available offline to contributors and coding agents. The canonical version on +> dart.dev wins where the two differ. Where [`STYLE_GUIDE.md`](STYLE_GUIDE.md) contradicts this +> document, the style guide wins. + +It's easy to think your code is obvious today without realizing how much you +rely on context already in your head. People new to your code, and +even your forgetful future self won't have that context. A concise, accurate +comment only takes a few seconds to write but can save one of those people +hours of time. + +We all know code should be self-documenting and not all comments are helpful. +But the reality is that most of us don't write as many comments as we should. +It's like exercise: you technically *can* do too much, but it's a lot more +likely that you're doing too little. Try to step it up. + +## Comments + +The following tips apply to comments that you don't want included in the +generated documentation. + +### DO format comments like sentences + +**Good:** + +```dart +// Not if anything comes before it. +if (_chunks.isNotEmpty) return false; +``` + +Capitalize the first word unless it's a case-sensitive identifier. End it with a +period (or "!" or "?", I suppose). This is true for all comments: doc comments, +inline stuff, even TODOs. Even if it's a sentence fragment. + +### DON'T use block comments for documentation + +**Good:** + +```dart +void greet(String name) { + // Assume we have a valid name. + print('Hi, $name!'); +} +``` + +**Bad:** + +```dart +void greet(String name) { + /* Assume we have a valid name. */ + print('Hi, $name!'); +} +``` + +You can use a block comment (`/* ... */`) to temporarily comment out a section +of code, but all other comments should use `//`. + +## Doc comments + +Doc comments are especially handy because [`dart doc`][] parses them +and generates [beautiful doc pages][docs] from them. +A doc comment is any comment that appears before a declaration +and uses the special `///` syntax that `dart doc` looks for. + +[`dart doc`]: /tools/dart-doc +[docs]: https://api.dart.dev + +### DO use `///` doc comments to document members and types + + +Using a doc comment instead of a regular comment enables +[`dart doc`][] to find it +and generate documentation for it. + +**Good:** + +```dart +/// The number of characters in this chunk when unsplit. +int get length => ... +``` + +**Bad:** + +```dart +// The number of characters in this chunk when unsplit. +int get length => ... +``` + +For historical reasons, `dart doc` supports two syntaxes of doc comments: `///` +("C# style") and `/** ... */` ("JavaDoc style"). We prefer `///` because it's +more compact. `/**` and `*/` add two content-free lines to a multiline doc +comment. The `///` syntax is also easier to read in some situations, such as +when a doc comment contains a bulleted list that uses `*` to mark list items. + +If you stumble onto code that still uses the JavaDoc style, consider cleaning it +up. + +### PREFER writing doc comments for public APIs + + +You don't have to document every single library, top-level variable, type, and +member, but you should document most of them. + +### CONSIDER writing a library-level doc comment + +Unlike languages like Java where the class is the only unit of program +organization, in Dart, a library is itself an entity that users work with +directly, import, and think about. That makes the `library` directive a great +place for documentation that introduces the reader to the main concepts and +functionality provided within. Consider including: + +* A single-sentence summary of what the library is for. +* Explanations of terminology used throughout the library. +* A couple of complete code samples that walk through using the API. +* Links to the most important or most commonly used classes and functions. +* Links to external references on the domain the library is concerned with. + +To document a library, place a doc comment before +the `library` directive and any annotations that might be attached +at the start of the file. + +**Good:** + +```dart +/// A really great test library. +@TestOn('browser') +library; +``` + +### CONSIDER writing doc comments for private APIs + +Doc comments aren't just for external consumers of your library's public API. +They can also be helpful for understanding private members that are called from +other parts of the library. + +### DO start doc comments with a single-sentence summary + +Start your doc comment with a brief, user-centric description ending with a +period. A sentence fragment is often sufficient. Provide just enough context for +the reader to orient themselves and decide if they should keep reading or look +elsewhere for the solution to their problem. + +**Good:** + +```dart +/// Deletes the file at [path] from the file system. +void delete(String path) { + ... +} +``` + +**Bad:** + +```dart +/// Depending on the state of the file system and the user's permissions, +/// certain operations may or may not be possible. If there is no file at +/// [path] or it can't be accessed, this function throws either [IOError] +/// or [PermissionError], respectively. Otherwise, this deletes the file. +void delete(String path) { + ... +} +``` + +### DO separate the first sentence of a doc comment into its own paragraph + +Add a blank line after the first sentence to split it out into its own +paragraph. If more than a single sentence of explanation is useful, put the +rest in later paragraphs. + +This helps you write a tight first sentence that summarizes the documentation. +Also, tools like `dart doc` use the first paragraph as a short summary in places +like lists of classes and members. + +**Good:** + +```dart +/// Deletes the file at [path]. +/// +/// Throws an [IOError] if the file could not be found. Throws a +/// [PermissionError] if the file is present but could not be deleted. +void delete(String path) { + ... +} +``` + +**Bad:** + +```dart +/// Deletes the file at [path]. Throws an [IOError] if the file could not +/// be found. Throws a [PermissionError] if the file is present but could +/// not be deleted. +void delete(String path) { + ... +} +``` + +### AVOID redundancy with the surrounding context + +The reader of a class's doc comment can clearly see the name of the class, what +interfaces it implements, etc. When reading docs for a member, the signature is +right there, and the enclosing class is obvious. None of that needs to be +spelled out in the doc comment. Instead, focus on explaining what the reader +*doesn't* already know. + +**Good:** + +```dart +class RadioButtonWidget extends Widget { + /// Sets the tooltip to [lines]. + /// + /// The lines should be word wrapped using the current font. + void tooltip(List lines) { + ... + } +} +``` + +**Bad:** + +```dart +class RadioButtonWidget extends Widget { + /// Sets the tooltip for this radio button widget to the list of strings in + /// [lines]. + void tooltip(List lines) { + ... + } +} +``` + +Only add doc comments when providing context, caveats, or usage details +not immediately obvious from the surrounding context. + + +### PREFER starting comments of a function or method with third-person verbs if its main purpose is a side effect + +The doc comment should focus on what the code *does*. + +**Good:** + +```dart +/// Connects to the server and fetches the query results. +Stream fetchResults(Query query) => ... + +/// Starts the stopwatch if not already running. +void start() => ... +``` + +### PREFER starting a non-boolean variable or property comment with a noun phrase + +The doc comment should stress what the property *is*. This is true even for +getters which may do calculation or other work. What the caller cares about is +the *result* of that work, not the work itself. + +**Good:** + +```dart +/// The current day of the week, where `0` is Sunday. +int weekday; + +/// The number of checked buttons on the page. +int get checkedCount => ... +``` + +### PREFER starting a boolean variable or property comment with "Whether" followed by a noun or gerund phrase + +The doc comment should clarify the states this variable represents. +This is true even for getters which may do calculation or other work. +What the caller cares about is the *result* of that work, not the work itself. + +**Good:** + +```dart +/// Whether the modal is currently displayed to the user. +bool isVisible; + +/// Whether the modal should confirm the user's intent on navigation. +bool get shouldConfirm => ... + +/// Whether resizing the current browser window will also resize the modal. +bool get canResize => ... +``` + +> This guideline intentionally doesn't include using "Whether or not". In many +> cases, usage of "or not" with "whether" is superfluous and can be omitted, +> especially when used in this context. + +### PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose + +If a method is *syntactically* a method, but *conceptually* it is a property, +and is therefore [named with a noun phrase or non-imperative verb phrase][parameterized_property_name], +it should also be documented as such. +Use a noun-phrase for such non-boolean functions, and +a phrase starting with "Whether" for such boolean functions, +just as for a syntactic property or variable. + +**Good:** + +```dart +/// The [index]th element of this iterable in iteration order. +E elementAt(int index); + +/// Whether this iterable contains an element equal to [element]. +bool contains(Object? element); +``` + +> This guideline should be applied based on whether the declaration is +> conceptually seen as a property. +> +> Sometimes a method has no side effects, and might +> conceptually be seen as a property, but is still +> simpler to name with a verb phrase like `list.take()`. +> Then a noun phrase should still be used to document it. +> _For example `Iterable.take` can be described as +> "The first \[count\] elements of ..."._ + +[parameterized_property_name]: design#prefer-a-noun-phrase-or-non-imperative-verb-phrase-for-a-function-or-method-if-returning-a-value-is-its-primary-purpose + +### DON'T write documentation for both the getter and setter of a property + +If a property has both a getter and a setter, then create a doc comment for +only one of them. `dart doc` treats the getter and setter like a single field, +and if both the getter and the setter have doc comments, then +`dart doc` discards the setter's doc comment. + +**Good:** + +```dart +/// The pH level of the water in the pool. +/// +/// Ranges from 0-14, representing acidic to basic, with 7 being neutral. +int get phLevel => ... +set phLevel(int level) => ... +``` + +**Bad:** + +```dart +/// The depth of the water in the pool, in meters. +int get waterDepth => ... + +/// Updates the water depth to a total of [meters] in height. +set waterDepth(int meters) => ... +``` + +### PREFER starting library or type comments with noun phrases + +Doc comments for classes are often the most important documentation in your +program. They describe the type's invariants, establish the terminology it uses, +and provide context to the other doc comments for the class's members. A little +extra effort here can make all of the other members simpler to document. + +The documentation should describe an *instance* of the type. + +**Good:** + +```dart +/// A chunk of non-breaking output text terminated by a hard or soft newline. +/// +/// ... +class Chunk { + ... +} +``` + +### CONSIDER including code samples in doc comments + +**Good:** + +````dart +/// The lesser of two numbers. +/// +/// ```dart +/// min(5, 3) == 3 +/// ``` +num min(num a, num b) => ... +```` + +Humans are great at generalizing from examples, so even a single code sample +makes an API easier to learn. + +### DO use square brackets in doc comments to refer to in-scope identifiers + + +If you surround things like variable, method, or type names in square brackets, +then `dart doc` looks up the name and links to the relevant API docs. +Parentheses are optional but can +clarify you're referring to a function or constructor. +The following partial doc comments illustrate a few cases +where these comment references can be helpful: + +**Good:** + +```dart +/// Throws a [StateError] if ... +/// +/// Similar to [anotherMethod()], but ... +``` + +To link to a member of a specific class, use the class name and member name, +separated by a dot: + +**Good:** + +```dart +/// Similar to [Duration.inDays], but handles fractional days. +``` + +The dot syntax can also be used to refer to named constructors. For the unnamed +constructor, use `.new` after the class name: + +**Good:** + +```dart +/// To create a point, call [Point.new] or use [Point.polar] to ... +``` + +To learn more about the references that +the analyzer and `dart doc` support in doc comments, +check out [Documentation comment references][]. + +[Documentation comment references]: /tools/doc-comments/references + +### DO use prose to explain parameters, return values, and exceptions + +Other languages use verbose tags and sections to describe what the parameters +and returns of a method are. + +**Bad:** + +```dart +/// Defines a flag with the given name and abbreviation. +/// +/// @param name The name of the flag. +/// @param abbr The abbreviation for the flag. +/// @returns The new flag. +/// @throws ArgumentError If there is already an option with +/// the given name or abbreviation. +Flag addFlag(String name, String abbreviation) => ... +``` + +The convention in Dart is to integrate that into the description of the method +and highlight parameters using square brackets. + +Consider having sections starting with "The \[parameter\]" to describe +parameters, with "Returns" for the returned value and "Throws" for exceptions. +Errors can be documented the same way as exceptions, +or just as requirements that must be satisfied, without documenting the +precise error which will be thrown. + +**Good:** + +```dart +/// Defines a flag with the given [name] and [abbreviation]. +/// +/// The [name] and [abbreviation] strings must not be empty. +/// +/// Returns a new flag. +/// +/// Throws a [DuplicateFlagException] if there is already an option named +/// [name] or there is already an option using the [abbreviation]. +Flag addFlag(String name, String abbreviation) => ... +``` + +### DO put doc comments before metadata annotations + +**Good:** + +```dart +/// A button that can be flipped on and off. +@Component(selector: 'toggle') +class ToggleComponent {} +``` + +**Bad:** + +```dart +@Component(selector: 'toggle') +/// A button that can be flipped on and off. +class ToggleComponent {} +``` + +## Markdown + +You are allowed to use most [markdown][] formatting in your doc comments and +`dart doc` will process it accordingly using the [markdown package.][] + +[markdown]: https://daringfireball.net/projects/markdown/ +[markdown package.]: https://pub.dev/packages/markdown + +There are tons of guides out there already to introduce you to Markdown. Its +universal popularity is why we chose it. Here's just a quick example to give you +a flavor of what's supported: + +````dart +/// This is a paragraph of regular text. +/// +/// This sentence has *two* _emphasized_ words (italics) and **two** +/// __strong__ ones (bold). +/// +/// A blank line creates a separate paragraph. It has some `inline code` +/// delimited using backticks. +/// +/// * Unordered lists. +/// * Look like ASCII bullet lists. +/// * You can also use `-` or `+`. +/// +/// 1. Numbered lists. +/// 2. Are, well, numbered. +/// 1. But the values don't matter. +/// +/// * You can nest lists too. +/// * They must be indented at least 4 spaces. +/// * (Well, 5 including the space after `///`.) +/// +/// Code blocks are fenced in triple backticks: +/// +/// ```dart +/// this.code +/// .will +/// .retain(its, formatting); +/// ``` +/// +/// The code language (for syntax highlighting) defaults to Dart. You can +/// specify it by putting the name of the language after the opening backticks: +/// +/// ```html +///

HTML is magical!

+/// ``` +/// +/// Links can be: +/// +/// * https://www.just-a-bare-url.com +/// * [with the URL inline](https://google.com) +/// * [or separated out][ref link] +/// +/// [ref link]: https://google.com +/// +/// # A Header +/// +/// ## A subheader +/// +/// ### A subsubheader +/// +/// #### If you need this many levels of headers, you're doing it wrong +```` + +### AVOID using markdown excessively + +When in doubt, format less. Formatting exists to illuminate your content, not +replace it. Words are what matter. + +### AVOID using HTML for formatting + +It *may* be useful to use it in rare cases for things like tables, but in almost +all cases, if it's too complex to express in Markdown, you're better off not +expressing it. + +### PREFER backtick fences for code blocks + +Markdown has two ways to indicate a block of code: indenting the code four +spaces on each line, or surrounding it in a pair of triple-backtick "fence" +lines. The former syntax is brittle when used inside things like Markdown lists +where indentation is already meaningful or when the code block itself contains +indented code. + +The backtick syntax avoids those indentation woes, lets you indicate the code's +language, and is consistent with using backticks for inline code. + +**Good:** + +```dart +/// You can use [CodeBlockExample] like this: +/// +/// ```dart +/// var example = CodeBlockExample(); +/// print(example.isItGreat); // "Yes." +/// ``` +``` + +**Bad:** + +```dart +/// You can use [CodeBlockExample] like this: +/// +/// var example = CodeBlockExample(); +/// print(example.isItGreat); // "Yes." +``` + +## Writing + +We think of ourselves as programmers, but most of the characters in a source +file are intended primarily for humans to read. English is the language we code +in to modify the brains of our coworkers. As for any programming language, it's +worth putting effort into improving your proficiency. + +This section lists a few guidelines for our docs. You can learn more about +best practices for technical writing, in general, from articles such as +[Technical writing style](https://en.wikiversity.org/wiki/Technical_writing_style). + +### PREFER brevity + +Be clear and precise, but also terse. + +### AVOID abbreviations and acronyms unless they are obvious + +Many people don't know what "i.e.", "e.g." and "et al." mean. That acronym +that you're sure everyone in your field knows may not be as widely known as you +think. + +### PREFER using "this" instead of "the" to refer to a member's instance + +When documenting a member for a class, you often need to refer back to the +object the member is being called on. Using "the" can be ambiguous. +Prefer having some qualifier after "this", a sole "this" can be ambiguous too. + +```dart +class Box { + /// The value this box wraps. + Object? _value; + + /// Whether this box contains a value. + bool get hasValue => _value != null; +} +``` diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md new file mode 100644 index 00000000..8adf0a58 --- /dev/null +++ b/ERROR_LAYER.md @@ -0,0 +1,255 @@ +# Stream Core — Error Layer + +How failures are modeled, produced, and handled across every Stream SDK (Chat, Video, Feeds). + +## The four exceptions + +Every failure a Stream SDK reports is a `StreamException`. There are exactly four kinds, named for +what the caller should do about them: + +``` +StreamException (sealed) message · cause +├── StreamApiException the server answered, and the answer was an error +├── StreamNetworkException the server was never heard from — outcome unknown +├── StreamAuthenticationException credentials could not be produced or sent +└── StreamClientException the SDK itself failed +``` + +```dart +sealed class StreamException implements Exception { + final String message; // always present, developer-readable; for user-facing UI, + // key your own strings off `code` (see localization note below) + final Object? cause; // the error underneath, when this wraps another +} + +base class StreamApiException extends StreamException { + final int statusCode; // HTTP status — independent of `code`; never derive one from the other + final StreamErrorCode? code; // Stream's stable error code — branch on this, never on message. + // Null when the verdict never reached Stream (a proxy's bare status) + final String? moreInfo; // docs URL, when the server sent one; most errors carry none + final bool unrecoverable; // when true, the server says retrying will not help — authoritative. + // Absence means nothing: a permission denial carries it, some Video + // errors do, and most errors never carry it at all. + final Duration? retryAfter; // from the Retry-After header on any error response carrying one; + // absent on WS rate limits + + bool get isTokenExpired; // code 40 — a fresh token fixes it + bool get isTokenNotYetValid; // codes 41, 42 — clock skew (nbf/iat); waiting fixes it, a fresh + // token from the same skewed clock does not + bool get isTokenSignatureInvalid; // code 43 — configuration problem; no token or wait fixes it + bool get isApiKeyInvalid; // code 2 — wrong key, or product not enabled on the app + bool get isRateLimited; // statusCode 429 +} + +base class StreamNetworkException extends StreamException { + final bool isCancelled; // the caller cancelled the request + final bool isTimeout; + final int? closeCode; // WebSocket close code, when the failure was a socket closure +} + +base class StreamAuthenticationException extends StreamException {} + +base class StreamClientException extends StreamException {} +``` + +## Which one? Three questions, asked in order + +``` +Did the Stream server answer with an error? → StreamApiException + no ↓ +Did credentials fail before anything reached it? → StreamAuthenticationException + no ↓ +Did the network / socket / timeout eat the request? → StreamNetworkException + no ↓ +It's our own code's fault → StreamClientException +``` + +One rule resolves the classic overlap: **a server that rejects your token has answered** — that is a +`StreamApiException` (check `isTokenExpired` / `isTokenSignatureInvalid`). `StreamAuthenticationException` is +only for credentials that never went out: the `TokenProvider` threw or returned nothing usable, no +user is configured, or the WebSocket auth message could not be sent. Whatever the provider threw is +preserved in `cause`. + +You will rarely see `isTokenExpired` yourself: the SDK refreshes expired tokens automatically — a +REST call refused with code 40 is retried once with a fresh token, and a reconnect passes the +refusal to the authenticator so it can send a fresh one. It surfaces only when refresh cannot help: +your provider is static, or the fresh token was refused too. + +## For app developers: catch by what you'd do + +| You caught | It means | You typically... | +|---|---|---| +| `StreamApiException` | the server said no; `message`/`code`/`moreInfo` say why | show your own copy keyed off `code`; branch on it for special cases | +| `StreamNetworkException` | the server was never heard from — the outcome is **unknown** | show offline UI, retry on connectivity; ignore if `isCancelled` | +| `StreamAuthenticationException` | your token provider / login setup is broken | send the user through your auth flow again | +| `StreamClientException` | the SDK (or a callback you gave it) hit an unexpected error | report to your crash tracker — not the end user's problem | + +`message` always describes the failure, but it is developer-facing English straight from the server +(REST errors even carry an internal controller-name prefix) — never show it verbatim as product UI. +For localized, user-worthy text, key your own strings off `code`. Core owns the code registry as +`StreamErrorCode` — an extension type over `int` with a named constant per known code, shared by +every product because the backend's registry is one shared space; a code the SDK does not know yet +still carries its number. `statusCode` and `code` are independent facts: the backend maps some +codes to more than one status, so never infer one from the other. + +Failures arrive on two channels, carrying the same four types: + +- **Operations** return `Result`; a `Failure` from an SDK operation always holds a + `StreamException` — every call runs through the seam that guarantees it (`runApiSafely`, below). + No runtime condition is thrown. Misuse still is: calling an operation wrongly raises `StateError` + or `ArgumentError`, which is a bug to fix rather than a failure to handle. +- **Connection lifecycle** failures arrive as state: `connectionState` emits + `Disconnected(source)`, where `source` says who ended the connection and carries the error when + there was one: + +```dart +client.connectionState.listen((state) { + if (state case Disconnected(:final source)) { + switch (source) { + case UserInitiated(): break; // you called disconnect() + case AuthenticationFailed(): _reLogin(); // credentials could not be produced or sent + case ServerInitiated(:final error): _onServerClosed(error); // the server ended it; the error (if any) says why + case _: _showReconnecting(); // transport trouble — the SDK retries + } + } +}); +``` + +```dart +final result = await client.sendMessage(...); +switch (result) { + case Success(:final data): + render(data); + case Failure(:final error): + // Narrowing here is what makes the switch below exhaustive. + if (error case final StreamException failure) { + switch (failure) { + case StreamApiException(isRateLimited: true): scheduleRetry(); + case StreamApiException(:final code?): showError(copyFor(code)); + case StreamApiException(): showError(genericFailureCopy); // no Stream code: a proxy's bare status + case StreamNetworkException(isCancelled: true): break; // user navigated away + case StreamNetworkException(): showOfflineBanner(); + case StreamAuthenticationException(): redirectToLogin(); + case StreamClientException(): reportToCrashTracker(failure); + } + } +} +``` + +The root is `sealed`, so a `switch` over a `StreamException` that misses a category does not +compile. `Failure.error` is typed `Object`, so narrow to the root first, as above, for the check to +apply. If you don't want to branch, `error.message` is always displayable and `on StreamException` +always catches everything Stream. + +**Bugs are not in this hierarchy.** Misusing the SDK — calling `send()` before `connect()`, using a +disposed client — throws Dart's own `StateError`/`ArgumentError`. +Those mean *fix your code*, not *handle at runtime*, and they never appear inside a `Result`. + +The naming follows the same line, and it is a signal to the catcher: a `…Exception` is a condition +that catching is the right response to; the `Error` suffix is reserved for Dart's bug hierarchy and +for non-throwable data models (`StreamApiError` is the server's wire payload, not a throwable). + +## For SDK developers: you rarely construct one + +Only **boundaries** create `StreamException`s. Everything above a boundary propagates `Result`s that +already carry the right type — if you are not writing a boundary, you never pick an exception. + +| Boundary | Produces | +|---|---| +| HTTP error mapper (the only file that reads Dio) | `StreamApiException` from a server error body or bare status; `StreamNetworkException` from timeout / cancel / socket errors | +| Response/event decoding | `StreamClientException` when wire data will not decode, whatever the decoder threw (see the seam rule below) | +| WebSocket engine + auth handler | `StreamNetworkException` for transport failures; `StreamAuthenticationException` when credentials couldn't be sent; server error events become `StreamApiException` — the inner error object is the same as REST, but it arrives in two envelopes (`{"type":"connection.error",...}` from the monolith, bare `{"error":{...}}` from the edge) and the decoder must accept both | +| `TokenManager` | `StreamAuthenticationException` when no user is configured, when a reset raced the load, or when the `TokenProvider` fails with something unclassified (preserved as `cause`); a provider failure that is already a `StreamException` passes through as itself, and a `TimeoutException` becomes a `StreamNetworkException`, so what is about the moment stays retriable | +| `runApiSafely` (the API call seam) | passes an existing `StreamException` through untouched; maps Dio failures to `StreamApiException`/`StreamNetworkException`; wraps anything else — `Exception` or `Error` alike — into `StreamClientException`, preserving `cause` | + +Both capture helpers catch **everything**, `Error` included — they differ in what they hand back: + +- **`runSafely`** (the generic capture) stores whatever was thrown in the `Failure` untouched — raw + truth, classified by the boundary above it via `StreamException.tryFrom` plus a kind-specific + fallback. +- **`runApiSafely`** (the API boundary) delivers only `StreamException`s. A `TypeError` thrown while + decoding wire data indicts the data, not the program — a server that renamed a field must surface + as a handleable failure, not a crash — so it arrives as a `StreamClientException` with the + original `Error` as `cause`. A `StateError` from a bug under the seam arrives the same way; it is + still a bug, so treat that `StreamClientException` as a crash report, not a condition to handle. + +The errors-vs-exceptions rule governs the throw site, not the catch site: SDK guards still throw +`StateError`/`ArgumentError` for misuse. A guard above any seam crashes loudly; one that fires under +a seam surfaces as the `cause` of a `StreamClientException`. + +A decode failure on a **live event stream** is the one failure with no operation to fail and no +reason to kill a healthy connection: drop the event, log it through the SDK logger, and count it — +never disconnect. This diagnostics path is the only place a failure is deliberately not delivered. + +Two wire facts every WS implementer must know: the server sends the error **text frame first, then +the close frame** — drain pending frames before reacting to a close, or the reason is lost. And the +close code carries almost no signal: auth, token, and permission rejections all close with **1000** +(normal closure); only 1011 (5xx), 1013 (rate limited — reconnect with backoff, no `Retry-After` +exists on WS) and 1012 (server restart) mean anything. Classify from the drained error event's +`code`, never from the close code alone. + +The three rules you actually need: + +1. **Misuse throws `Error`, conditions become `StreamException`.** Ask: "can this happen to a + correct program at runtime?" No → `StateError`/`ArgumentError`, never wrapped. Yes → the + three-question tree above says which exception. +2. **A new exception type needs a new reaction to justify it.** If the catcher of your proposed type + would do the same thing they'd do for an existing category, it is not a new type — it is a field + or a `code`. Context (like "which item of a batch failed") travels in the data channel, beside + the outcome, never by wrapping one category inside another. +3. **A trace records the raise, not the failure.** `StreamException` carries `message` and `cause` + and no `stackTrace`. Thrown, the language holds it — `rethrow`, or `Error.throwWithStackTrace` + when re-raising something that failed elsewhere. Returned as data, the carrier holds it: + `Failure(error, stackTrace)`, `ServerInitiated(error, stackTrace)`. Where nothing raised it — a + closure decoded off the wire — it stays `null`; `StackTrace.current` there manufactures a trace + pointing at the decoder. + +Product SDKs (Chat, Video, Feeds) may extend a category — `StreamChatApiException extends +StreamApiException` — but never add a fifth top-level kind and never re-map a core exception into an +unrelated type. + +## Retrying + +The exception carries **facts** (`statusCode`, `code`, `unrecoverable`, `retryAfter`, `isTimeout`, +`closeCode`); whether to retry is **policy** the caller owns. Retryability is a function of three +inputs — what happened (the exception), what the caller was doing (idempotent or not), and how many +attempts have been spent — which is why no `isRetryable` lives on the exception: it only knows the +first input. + +The decision runs in order: + +1. **Honor the server's explicit verdicts.** `unrecoverable: true` → never retry. `retryAfter` → + retry, but only after that wait. +2. **Decide by kind and facts.** Retry what is about *the moment*; never what is about *the request + or the setup*: + + | Failure | Retry? | + |---|---| + | `StreamNetworkException(isCancelled: true)` | No — the caller stopped it. | + | `StreamNetworkException` otherwise | Yes for reads; writes only through an idempotent path. Prefer a connectivity signal over blind backoff. | + | `StreamApiException(isRateLimited: true)` | Yes, after `retryAfter` (backoff when absent). | + | `StreamApiException`, 5xx | Yes, with backoff. | + | `StreamApiException(isTokenExpired: true)` | No — the SDK already refreshed and retried once; seeing it means refresh could not help. | + | `StreamApiException(isTokenNotYetValid: true)` | Yes, after waiting — clock skew heals, bounded. | + | `StreamApiException`, 408 (code 48) | Yes, with backoff — the request did not complete in time, which is not a verdict on the request. | + | `StreamApiException`, any other 4xx | No — the same request gets the same verdict. (A channel cooldown, code 60, does clear on its own, but its wait is not machine-readable — surface it rather than auto-retry.) | + | `StreamAuthenticationException` | No — fix credentials first, then re-attempt the operation. | + | `StreamClientException` | No — a bug does not heal on resend; report it. | + +3. **Apply the budget**: max attempts, exponential backoff with jitter, a delay cap. + +`DisconnectionSource.isReconnectable` is this procedure specialized for the connection, and the +interceptor's one-shot token refresh is the code-40 row. It answers three rows differently: an +expired token reconnects, because the reconnect authenticates again and an operation retry does not; +an SDK failure reconnects; and every 4xx, 408 included, does not. This table is the authority for +operations, the `isReconnectable` dartdoc for the connection. + +What remains for callers is operation retry: steps 1–2 answer *whether* from the error alone +(necessary, but not sufficient, since the error cannot know the operation's idempotency), *when* +comes from `retryAfter` where the server named a wait and from the caller's backoff otherwise, and +the budget is the caller's. Product SDKs compose this into their retry queues. + +One honesty rule about retrying writes: a `StreamNetworkException` means the outcome is **unknown** +— the server may have performed the operation. Retry a write only through an idempotent path +(product SDKs use client-generated ids for this: re-sending a message with the same id cannot +duplicate it). diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 04191471..23d2f976 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -71,6 +71,9 @@ document; the section link is provided. - File names are `snake_case.dart` (`file_names`). Imports follow the standard order: `dart:` → `package:` → relative — one blank line between groups (`directives_ordering`). +- Misuse throws `StateError`/`ArgumentError`; runtime conditions raise a + `StreamException` kind, and throwable names end in `…Exception`. → + [Errors vs exceptions](#errors-vs-exceptions) **Design system** @@ -373,8 +376,12 @@ Public dartdocs are encouraged but currently **not lint-enforced** (`public_member_api_docs` is disabled in `analysis_options.yaml`; this is temporary while the repo catches up). New public code should still ship with dartdocs. -In general, follow the [Effective Dart documentation guide](https://dart.dev/effective-dart/documentation) -except where this page contradicts it. +In general, follow the Effective Dart documentation guide — vendored in this repo as +[`EFFECTIVE_DART_DOC.md`](EFFECTIVE_DART_DOC.md) so it is readable offline +(canonical version at [dart.dev](https://dart.dev/effective-dart/documentation)) — except where +this page contradicts it. Read it before writing or reviewing dartdoc: the rules most often +missed are single-sentence first paragraphs, "Whether…" for booleans, noun phrases for +properties, square brackets for in-scope identifiers, and throws documented in prose. ### Answer your own questions straight away @@ -567,6 +574,45 @@ assert(size > 0); assert(!_disposed, 'StreamAvatarController used after dispose()'); ``` +### Errors vs exceptions + +Dart splits the two words by who is at fault and what should happen next, and this +repo follows the split strictly: + +- An **`Exception`** is a runtime condition a correct program can encounter — the + network dropped, the server said no, a token expired. Exceptions are part of the + API contract: callers are expected to catch and handle them. +- An **`Error`** is a programmer mistake — `StateError`, `ArgumentError`, + `TypeError`. Errors are meant to fail fast and loud, not be caught: handling one + papers over a bug. + +When raising a failure, ask one question: *can this happen to a correct program at +runtime?* + +| Answer | Raise | Examples | +|---|---|---| +| No — the caller misused the API | `StateError` / `ArgumentError`, never wrapped, never inside a `Result` | `connect()` on a disposed client, a negative replay count | +| Yes — it is a condition to handle | the fitting `StreamException` kind | a refused request, a dropped socket, a failed token load | + +Which of the four `StreamException` kinds fits — and which layer produces which — is +the subject of [`ERROR_LAYER.md`](ERROR_LAYER.md); the three-question tree there +gives every failure exactly one home. In `stream_core_flutter`, prefer catching the +exception kinds over `StreamException` itself so the reaction can differ per kind. + +Naming follows the same line: public throwable types end in `…Exception`; the +`Error` suffix is reserved for Dart's bug hierarchy and for non-throwable data +models (`StreamApiError` is the server's wire payload, not a throwable). "Error" +remains fine as a domain word in prose, fields, and codes (`StreamErrorCode`, +`errorBuilder`). + +The capture seams deliberately cross the don't-catch-`Error` line, each with a +stated reason: `runApiSafely` and wire decoding catch everything, because a +`TypeError` there indicts the data rather than the program; the auth boundaries +catch everything thrown by app-supplied token code, because a rejection must +always deliver a `StreamException` (the original error stays visible in `cause`); +and `runSafely` captures raw truth for the boundary above it to classify. Outside +a seam, an `Error` propagates to the crash reporter where it belongs. + ### Prefer specialized functions, methods, and constructors Use the most relevant constructor when there are multiple options. @@ -744,7 +790,8 @@ do it. For classes that appear in error messages or logs, override `toString`. Avoid bare `$runtimeType` — use `objectRuntimeType(this, 'ClassName')`, which strips runtime -type at release-mode. +type at release-mode. Flutter code gets it from `package:flutter/foundation.dart`; +in `stream_core` it is package-internal, imported from `src/utils/object.dart`. ### Be explicit about `dispose()` and the object lifecycle @@ -1383,9 +1430,10 @@ entries are acceptable for user-visible multi-facet features where the extra context matters to someone deciding whether to upgrade — but avoid sub-bullets, per-method enumeration, and internal implementation notes. -Older entries in the changelog use `### 🐞 Fixed` and `### 💥 Breaking Changes` / -`### 💥 BREAKING CHANGES` — those forms are grandfathered but new entries should -use the labels above. +Some changelogs use older labels — `### 🐞 Fixed`, `### 💥 Breaking Changes` / +`### 💥 BREAKING CHANGES`. Match the header style the package's changelog already +uses rather than mixing forms within one file; a new package starts on the labels +above. ### Cross-package PRs diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d556cfc9..5b2cada8 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -9,14 +9,23 @@ - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise - `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, called once per connection attempt - `WebSocketOptions.connectTimeout` is now a non-nullable `Duration`, 30 seconds by default, and is honoured: a connection that does not come up is given up on instead of waited on indefinitely. A connection that drops later is retried for you; a `connect` that times out is not, so call it again -- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the error the server closed the previous attempt with, and throws to say the credentials did not go out -- Removed `WebSocketEngineException.stopErrorCode`, use `CloseCode.normalClosure` +- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out +- Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract +- `StreamException` no longer takes or carries a `stackTrace`. A trace records where a failure was raised rather than what it was, so it travels beside the failure: on `Failure`, or on `ServerInitiated` and `AuthenticationFailed` for a connection that closed. Where the exception is built at the throw site, `throw` captures it +- Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and the `StreamDioExceptionExtension` extension is now `DioExceptionMapping`, with `toClientException()` renamed to `toStreamException()` +- `StreamWebSocketClient.send` now fails with a `StreamException` rather than passing the engine's own error through, so a `Failure` that carried a `StateError` or a codec error now carries a `StreamNetworkException` or `StreamClientException`. The `WsRequestSender` handed to a `WebSocketAuthenticator` changed the same way, which matters where its error is propagated into `AuthenticationFailed` +- `StreamWebSocketClient.send` throws a `StateError` when nothing has connected yet, rather than reporting it through the returned `Result` +- `StreamDioException` no longer defaults its `stackTrace` to `StackTrace.current`, leaving Dio to substitute the stack captured where the request was made +- `ServerInitiated.error` is typed `StreamException?` rather than `WebSocketEngineException?` +- `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause`. A provider failure that is already a `StreamException`, or a `TimeoutException`, keeps its own kind, so a load that failed at the moment stays retriable +- Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` +- `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged - `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another -- `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix -- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal, rate limits and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` +- The package no longer re-exports `dart:typed_data`, so code that reached `Uint8List` through `package:stream_core/stream_core.dart` must import `dart:typed_data` itself ### ✨ Features @@ -30,15 +39,21 @@ - Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision -- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else +- Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents +- Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on any error response carrying one — a 503 populates it as readily as a 429. Only the delta-seconds form is read +- Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet +- Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none +- Added `stackTrace` to `ServerInitiated` and `AuthenticationFailed`, where the failure was raised; `null` for a closure the server reported, which arrives as data rather than as something raised - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable`, and `connect` throws a `StateError` afterwards - Added `InFlightCache`, which hands concurrent callers asking for the same key the one call already in flight, and its outcome, success or failure alike ### 🐛 Bug Fixes +- Fixed `StreamApiError` failing to decode when `details` is not a list of numbers, as a moderation rejection's is; such values read as empty +- Fixed an error payload without a `duration` or `more_info` failing to decode, which lost the `code` with it and silently stopped the token refresh a code would have triggered. Both read as empty now - Fixed three faults in `TokenManager`'s token cache: `getToken` contacted the provider on every call instead of returning the cached token, handed out a token that had already expired rather than replacing it, and cached one that finished loading after `expireToken` or `setTokenProvider` had invalidated it. A static provider is left alone, having nothing fresher to give - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested - Fixed several faults in the token-expired retry: it was skipped when the response carried no JSON content type, never completed at all when the replacement was refused too, re-sent a multipart body whose streams the refused attempt had consumed, and expired a token another request had already replaced diff --git a/packages/stream_core/doc/web_socket.md b/packages/stream_core/doc/web_socket.md index 5f4178ee..168805f2 100644 --- a/packages/stream_core/doc/web_socket.md +++ b/packages/stream_core/doc/web_socket.md @@ -76,26 +76,35 @@ Creates a [StreamWebSocketClient] instance for real-time WebSocket communication ```dart StreamWebSocketClient({ - required WebSocketOptions options, + required WebSocketOptionsBuilder optionsBuilder, required WebSocketMessageCodec messageCodec, + WebSocketAuthenticator? onAuthenticate, PingRequestBuilder pingRequestBuilder = _defaultPingRequestBuilder, - void Function()? onConnectionEstablished, Iterable>? eventResolvers, + String tag = 'SC:WsClient', }) ``` -The [options] specify connection configuration including URL, protocols, and query parameters. The [messageCodec] handles encoding outgoing requests and decoding incoming events. When [onConnectionEstablished] is provided, it's called when the connection is ready for authentication. +The [optionsBuilder] is called once per attempt, so the URL and query parameters can change between +them. The [messageCodec] handles encoding outgoing requests and decoding incoming events. When +[onAuthenticate] is provided, it is called once the socket is open and is where credentials go out. ### Authentication -When authentication is required, send authentication messages in the [onConnectionEstablished] callback: +When authentication is required, send the credentials from [onAuthenticate]. It is handed a sender +and the error the server closed the previous attempt with, so a refused token can be replaced rather +than resent: ```dart final client = StreamWebSocketClient( - options: WebSocketOptions(url: 'wss://api.example.com'), + optionsBuilder: () => const WebSocketOptions(url: 'wss://api.example.com'), messageCodec: MyMessageCodec(), - onConnectionEstablished: () { - client.send(AuthRequest(token: authToken)); + onAuthenticate: (send, previousError) async { + // A refused token is replaced rather than resent. + if (previousError?.isTokenExpired ?? false) tokenManager.expireToken(); + + final token = await tokenManager.getToken(); + send(AuthRequest(token: token.rawValue)).getOrThrow(); }, ); ``` @@ -157,22 +166,17 @@ For mobile apps, include network and app lifecycle monitoring: final recoveryHandler = ConnectionRecoveryHandler( client: client, networkStateProvider: NetworkStateProvider(), - appLifecycleStateProvider: AppLifecycleStateProvider(), + lifecycleStateProvider: myLifecycleStateProvider, ); ``` ### Reconnection Rules -Automatic reconnection is **enabled** for: -- Server-initiated disconnections (except authentication/client errors) -- System-initiated disconnections (network changes, etc.) -- Unhealthy connections (missing pong responses) +Whether a closure is worth opening again is decided by `DisconnectionSource.isReconnectable`, which +reads the facts the error carries rather than the close code. Its dartdoc lists every case. -Automatic reconnection is **disabled** for: -- User-initiated disconnections -- Server errors with code 1000 (normal closure) -- Token invalid/expired errors -- Client errors (4xx status codes) +Being reconnectable is necessary but not sufficient: `ConnectionRecoveryHandler` recovers only a +connection that was established, and only while the network and the app lifecycle allow it. ## Event Resolvers @@ -227,12 +231,13 @@ client.connectionState.on((state) { ### Sending Messages -Sends a message through the WebSocket connection. +Sends a message over an established connection. Throws a `StateError` when the client has not been +connected. ```dart final result = client.send(MyRequest(data: 'hello')); if (result.isFailure) { - print('Failed to send message: ${result.error}'); + print('Failed to send message: ${result.exceptionOrNull()}'); } ``` @@ -262,13 +267,13 @@ final messageCodec = JsonMessageCodec(); // 2. Create WebSocket client final client = StreamWebSocketClient( - options: WebSocketOptions( + optionsBuilder: () => WebSocketOptions( url: 'wss://api.example.com/ws', queryParameters: {'token': authToken}, ), messageCodec: messageCodec, - onConnectionEstablished: () { - client.send(AuthRequest(token: authToken)); + onAuthenticate: (send, _) async { + send(AuthRequest(token: authToken)).getOrThrow(); }, ); @@ -276,7 +281,7 @@ final client = StreamWebSocketClient( final recoveryHandler = ConnectionRecoveryHandler( client: client, networkStateProvider: NetworkStateProvider(), - appLifecycleStateProvider: AppLifecycleStateProvider(), + lifecycleStateProvider: myLifecycleStateProvider, ); // 4. Listen to events @@ -300,7 +305,7 @@ await client.connect(); // 7. Send messages final result = client.send(ChatMessage(content: 'Hello, World!')); if (result.isFailure) { - print('Send failed: ${result.error}'); + print('Send failed: ${result.exceptionOrNull()}'); } // 8. Clean up when done diff --git a/packages/stream_core/lib/src/api.dart b/packages/stream_core/lib/src/api.dart index 8e26ae17..2754140a 100644 --- a/packages/stream_core/lib/src/api.dart +++ b/packages/stream_core/lib/src/api.dart @@ -4,7 +4,7 @@ export 'api/interceptors/auth_interceptor.dart'; export 'api/interceptors/connection_id_interceptor.dart'; export 'api/interceptors/headers_interceptor.dart'; export 'api/interceptors/logging_interceptor.dart'; -export 'api/stream_core_dio_error.dart'; +export 'api/stream_core_dio_exception.dart'; export 'api/stream_core_http_client.dart'; export 'api/stream_datetime_converter.dart'; export 'api/system_environment.dart'; diff --git a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart index 4b1a53a1..fade32d3 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart @@ -1,7 +1,12 @@ import 'package:dio/dio.dart'; -import '../stream_core_dio_error.dart'; +import '../stream_core_dio_exception.dart'; +/// Interceptor that maps every failed request onto a [StreamDioException] +/// carrying the `StreamException` it represents. +/// +/// Installed last, so every rejection leaving the HTTP client — whatever +/// interceptor or transport produced it — delivers a Stream exception. class ApiErrorInterceptor extends Interceptor { /// Creates a new [ApiErrorInterceptor]. const ApiErrorInterceptor(); @@ -12,14 +17,12 @@ class ApiErrorInterceptor extends Interceptor { ErrorInterceptorHandler handler, ) { if (err is StreamDioException) { - // If the error is already a StreamDioException, - // we can directly pass it to the handler. + // Already carries a StreamException; pass it along unchanged. return super.onError(err, handler); } - // Otherwise, we convert the DioException to a StreamDioException final streamDioException = StreamDioException( - exception: err.toClientException(), + exception: err.toStreamException(), requestOptions: err.requestOptions, response: err.response, type: err.type, diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 664d55ec..696e25da 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -3,7 +3,7 @@ import 'package:dio/dio.dart'; import '../../errors.dart'; import '../../logger.dart'; import '../../user.dart'; -import '../stream_core_dio_error.dart'; +import '../stream_core_dio_exception.dart'; /// Interceptor that signs every request with the caller's token. /// @@ -37,17 +37,19 @@ class AuthInterceptor extends Interceptor { options.headers['stream-auth-type'] = token.authType.headerValue; return handler.next(options); - } catch (e, stackTrace) { - _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); - - final error = ClientException( - message: 'Failed to load auth token', - stackTrace: stackTrace, - error: e, + } catch (error, stackTrace) { + _logger.w(() => 'no token to sign ${options.uri} with', error: error, stackTrace: stackTrace); + + // Caught in full: a rejection must deliver a StreamException whatever + // the app's token code threw. + var exception = StreamException.tryFrom(error); + exception ??= StreamAuthenticationException( + message: 'Failed to load an auth token', + cause: error, ); final dioError = StreamDioException( - exception: error, + exception: exception, requestOptions: options, stackTrace: stackTrace, ); @@ -61,8 +63,11 @@ class AuthInterceptor extends Interceptor { DioException err, ErrorInterceptorHandler handler, ) async { - final error = err.apiError; - if (error == null || !error.isTokenExpiredError) return handler.next(err); + // Only an expired token (code 40) is fixed by loading another one; the + // other token codes are clock or configuration problems a refresh cannot + // help. + final error = err.toStreamException(); + if (error is! StreamApiException || !error.isTokenExpired) return handler.next(err); final options = err.requestOptions; diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart deleted file mode 100644 index 018fc8db..00000000 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:convert'; - -import '../../stream_core.dart'; - -/// A [DioException] carrying the Stream [ClientException] that caused it. -class StreamDioException extends DioException { - /// Creates a [StreamDioException] for [exception]. - StreamDioException({ - required this.exception, - required super.requestOptions, - super.response, - super.type, - StackTrace? stackTrace, - super.message, - }) : super( - error: exception, - stackTrace: stackTrace ?? StackTrace.current, - ); - - final ClientException exception; -} - -extension StreamDioExceptionExtension on DioException { - /// The Stream API error this exception's response carried, or `null` when it carried something else. - /// - /// A Stream error is recognised whether the server sent it as JSON or as plain text. A body that is - /// not one — a proxy or gateway answering with an error of its own — reads as `null` rather than - /// throwing. - StreamApiError? get apiError => runSafelySync(() { - final data = response?.data; - final json = data is String ? jsonDecode(data) : data; - if (json is! Map) return null; - - return StreamApiError.fromJson(json); - }).getOrNull(); - - /// This exception as an [HttpClientException]. - /// - /// Takes its message, status code and cause from [apiError] when the response carried one, and - /// from what the transport reported otherwise. A request the caller cancelled is marked as such. - HttpClientException toClientException() { - final apiError = this.apiError; - - return HttpClientException( - message: apiError?.message ?? response?.statusMessage ?? message ?? '', - error: apiError ?? this, - statusCode: apiError?.statusCode ?? response?.statusCode, - stackTrace: stackTrace, - isRequestCancelledError: type == DioExceptionType.cancel, - ); - } -} diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart new file mode 100644 index 00000000..fab18a47 --- /dev/null +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../errors.dart'; +import '../utils/result.dart'; + +/// A [DioException] carrying the [StreamException] that caused it. +/// +/// Dio requires rejections to be [DioException]s, so the mapped exception +/// rides in [exception] until [runApiSafely] unwraps it at the call seam. +class StreamDioException extends DioException { + /// Creates a [StreamDioException] carrying [exception]. + /// + /// A null [stackTrace] is left for Dio to fill in, which substitutes the + /// stack captured where the request was made — more useful than one + /// captured here. + StreamDioException({ + required this.exception, + required super.requestOptions, + super.response, + super.type, + super.stackTrace, + super.message, + }) : super(error: exception); + + /// The Stream exception this Dio exception delivers. + final StreamException exception; +} + +/// Maps failures reported by Dio onto the Stream exception kinds. +extension DioExceptionMapping on DioException { + /// This failure as the [StreamException] it represents. + /// + /// An answer from the server becomes a [StreamApiException], a transport + /// failure a [StreamNetworkException], and data that could not be read a + /// [StreamClientException]. + StreamException toStreamException() { + if (this case StreamDioException(:final exception)) return exception; + + // A Stream exception thrown loose inside the interceptor chain arrives + // wrapped in a plain DioException; its classification is kept rather + // than re-diagnosed from a wrapper that has no response to read. + if (error case final StreamException exception) return exception; + + if (type == .cancel) { + return StreamNetworkException( + message: 'The request was cancelled', + isCancelled: true, + cause: this, + ); + } + + if (type case .connectionTimeout || .sendTimeout || .receiveTimeout) { + return StreamNetworkException( + message: 'The request timed out before the server answered', + isTimeout: true, + cause: this, + ); + } + + // A response means the server reached a verdict, even when its body is + // not a Stream error payload — an edge or proxy answering on its own. + if (response case final response?) { + final retryAfter = _parseRetryAfter(response); + + if (_parseApiError(response.data) case final apiError?) { + return StreamApiException.fromApiError( + apiError, + retryAfter: retryAfter, + cause: this, + ); + } + + return StreamApiException( + message: response.statusMessage ?? message ?? 'The server responded with an error', + statusCode: response.statusCode ?? 0, + retryAfter: retryAfter, + cause: this, + ); + } + + // Dio reports transport trouble under its own types, so a failure carrying + // neither a response nor one of those came out of its own pipeline: a body + // it could not decode, or a request it could not build. + if (error case FormatException() || TypeError()) { + return StreamClientException( + message: 'The request could not be completed', + cause: error, + ); + } + + return StreamNetworkException( + message: message ?? 'The request failed before the server answered', + cause: this, + ); + } +} + +// An interpretation seam: decoding wire data catches everything, `Error` +// included — a `TypeError` thrown while reading a response body indicts the +// data, not the program. +StreamApiError? _parseApiError(Object? data) { + try { + final json = data is String ? jsonDecode(data) : data; + if (json is! Map) return null; + return StreamApiError.fromJson(json); + } catch (_) { + return null; + } +} + +Duration? _parseRetryAfter(Response response) { + final values = response.headers['retry-after']; + if (values == null || values.isEmpty) return null; + + // Only the delta-seconds form is read; RFC 9110 also allows an HTTP date. + final seconds = int.tryParse(values.first); + if (seconds == null || seconds < 0) return null; + return Duration(seconds: seconds); +} + +/// Runs a block of API code and returns a [Result] containing the outcome. +/// +/// If the block completes successfully, the result is a success with the value +/// returned by the block. Otherwise, the failure is always a [StreamException]: +/// a [DioException] is mapped through [DioExceptionMapping.toStreamException], +/// a [StreamException] is kept as it was raised, and anything else — a +/// response body that would not decode included — is wrapped in a +/// [StreamClientException] with the original error preserved as its cause. +Future> runApiSafely(FutureOr Function() block) async { + try { + final result = await block(); + return Result.success(result); + } on DioException catch (e, stackTrace) { + final exception = e.toStreamException(); + return Result.failure(exception, stackTrace); + } on StreamException catch (e, stackTrace) { + return Result.failure(e, stackTrace); + } catch (e, stackTrace) { + final exception = StreamClientException( + message: 'The API call failed unexpectedly', + cause: e, + ); + + return Result.failure(exception, stackTrace); + } +} diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index e74197a8..63a5dc0e 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,2 +1,3 @@ -export 'errors/client_exception.dart'; export 'errors/stream_api_error.dart'; +export 'errors/stream_error_code.dart'; +export 'errors/stream_exception.dart'; diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart deleted file mode 100644 index a053faea..00000000 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'stream_api_error.dart'; - -class ClientException implements Exception { - ClientException({ - this.message, - Object? error, - this.stackTrace, - }) { - underlyingError = error; - if (error is StreamApiError) { - apiError = error; - } else { - apiError = null; - } - } - - final String? message; - - late final Object? underlyingError; - late final StreamApiError? apiError; - final StackTrace? stackTrace; -} - -class HttpClientException extends ClientException { - HttpClientException({ - super.message, - super.error, - super.stackTrace, - required this.statusCode, - required this.isRequestCancelledError, - }); - final int? statusCode; - final bool isRequestCancelledError; -} - -// class WebSocketException extends ClientException { -// WebSocketException(this.serverException, {super.error}) -// : super( -// message: -// (serverException ?? WebSocketEngineException.unknown()).reason, -// ); -// final WebSocketEngineException? serverException; -// } -// -// class WebSocketEngineException extends ClientException { -// WebSocketEngineException({ -// required this.reason, -// required this.code, -// this.engineError, -// }) : super(message: reason); -// -// WebSocketEngineException.unknown() -// : this( -// reason: 'Unknown', -// code: 0, -// engineError: null, -// ); -// -// static const stopErrorCode = 1000; -// -// final String reason; -// final int code; -// final Object? engineError; -// } diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 0c8c2be0..76707a82 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'stream_error_code.dart'; + part 'stream_api_error.g.dart'; /// An API error response from the Stream API. @@ -27,12 +29,23 @@ class StreamApiError extends Equatable { }); /// The specific error code identifying the type of error. - final int code; + /// + /// [StreamErrorCode] names the known values; a code without a named + /// constant still carries its number. + @JsonKey(fromJson: StreamErrorCode.fromJson, toJson: StreamErrorCode.toJson) + final StreamErrorCode code; /// Additional error detail codes providing more context. + /// + /// Anything that is not a number reads as absent rather than failing the + /// whole error. + @JsonKey(fromJson: _detailsFromJson) final List details; /// The processing duration before the error occurred. + /// + /// Empty when the error arrived without one. + @JsonKey(defaultValue: '') final String duration; /// Additional context about the exception as key-value pairs. @@ -42,6 +55,9 @@ class StreamApiError extends Equatable { final String message; /// Additional information or documentation URL for this error. + /// + /// Empty when the error arrived without one. + @JsonKey(defaultValue: '') final String moreInfo; /// The HTTP status code associated with this error. @@ -49,6 +65,10 @@ class StreamApiError extends Equatable { final int statusCode; /// Whether this error is unrecoverable and should not be retried. + /// + /// Worth consulting on any product: a permission denial carries it, as do + /// some Video errors. Absence means nothing: most errors never carry it, + /// and `null` or `false` must not be read as "retrying will help". final bool? unrecoverable; Map toJson() => _$StreamApiErrorToJson(this); @@ -69,38 +89,20 @@ class StreamApiError extends Equatable { ]; } -// The token this was issued for has expired; another one is accepted. -const _expiredTokenCode = 40; - -// The token cannot be accepted for a reason another token does not fix: not -// valid yet, used before it was issued, or signed with the wrong secret. -final _invalidTokenCodes = _range(41, 43); - -// The API key itself is wrong, which no token repairs either. -const _accessKeyErrorCode = 2; - -final _clientErrorStatusCodes = _range(400, 499); - -/// Extension methods for [StreamApiError] to provide convenient error type checks. -extension StreamApiErrorExtension on StreamApiError { - /// Whether the token has expired (error code 40). - /// - /// Distinct from [isInvalidTokenError]: an expired token is fixed by loading another one, whereas - /// an invalid token is a configuration problem that a fresh token reproduces. - bool get isTokenExpiredError => code == _expiredTokenCode; - - /// Whether the token, or the API key it was signed with, cannot be accepted - /// (error codes 41 to 43, and 2). - bool get isInvalidTokenError => _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; - - /// Whether this error is a client-side error (4xx status codes). - bool get isClientError => _clientErrorStatusCodes.contains(statusCode); - - /// Whether this error indicates rate limiting (429 status code). - bool get isRateLimitError => statusCode == 429; +// The wire value is not guaranteed to be a list of ints: a moderation +// rejection (code 73) sends a list of objects, and the field can arrive as an +// object outright. Tolerating every shape keeps error decoding from failing +// exactly when an app needs the error. +List _detailsFromJson(Object? json) { + if (json is! List) return const []; + return [for (final entry in json.whereType()) entry.toInt()]; } -// Helper function to generate a range of integers from [from] to [to] inclusive. -List _range(int from, int to) { - return List.generate(to - from + 1, (i) => i + from); +/// Convenience predicates over the payload's [StreamApiError.statusCode]. +/// +/// The code-based predicates live on [StreamErrorCode] itself — consider +/// `error.code.isTokenExpired` and its siblings. +extension StreamApiErrorPredicates on StreamApiError { + /// Whether the request was rate limited (HTTP 429). + bool get isRateLimited => statusCode == 429; } diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index e3fd6139..7bea0229 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -7,20 +7,20 @@ part of 'stream_api_error.dart'; // ************************************************************************** StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiError( - code: (json['code'] as num).toInt(), - details: (json['details'] as List).map((e) => (e as num).toInt()).toList(), - duration: json['duration'] as String, + code: StreamErrorCode.fromJson(json['code'] as num), + details: _detailsFromJson(json['details']), + duration: json['duration'] as String? ?? '', exceptionFields: (json['exception_fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), message: json['message'] as String, - moreInfo: json['more_info'] as String, + moreInfo: json['more_info'] as String? ?? '', statusCode: (json['StatusCode'] as num).toInt(), unrecoverable: json['unrecoverable'] as bool?, ); Map _$StreamApiErrorToJson(StreamApiError instance) => { - 'code': instance.code, + 'code': StreamErrorCode.toJson(instance.code), 'details': instance.details, 'duration': instance.duration, 'exception_fields': instance.exceptionFields, diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart new file mode 100644 index 00000000..9508032c --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -0,0 +1,197 @@ +/// A strongly-typed wrapper around a Stream API error code. +/// +/// The named constants cover the codes the API is known to send, across every +/// Stream product — the registry is one shared space, so a chat and a video +/// error draw from the same numbers. The type behaves like an `int` at +/// runtime, so a code the SDK does not know yet still compares and prints as +/// its number rather than failing to decode. +/// +/// A code identifies the *condition*; the HTTP status it arrives with can +/// vary, so neither is derivable from the other. +extension type const StreamErrorCode(int code) implements int { + /// Create a new instance from a json number. + /// + /// Accepts any [num] the way every int field does, so an integral double + /// reads as its number instead of failing the whole error. + static StreamErrorCode fromJson(num code) => StreamErrorCode(code.toInt()); + + /// Serialize to json number. + static int toJson(StreamErrorCode code) => code; + + /// `-1` – An unexpected server-side failure. + static const internalError = StreamErrorCode(-1); + + /// `2` – The API key cannot be accepted: unknown, or the product it + /// addresses is not enabled for the app. + static const apiKeyInvalid = StreamErrorCode(2); + + /// `4` – The request could not be acted on as sent: input that failed + /// validation, or a feature the app has not configured. + static const inputError = StreamErrorCode(4); + + /// `5` – Authentication failed for a reason other than the token codes + /// below. + static const authenticationFailed = StreamErrorCode(5); + + /// `6` – The username is already taken. + static const duplicateUsername = StreamErrorCode(6); + + /// `9` – The request was rate limited. + static const rateLimited = StreamErrorCode(9); + + /// `16` – The requested resource does not exist. + static const notFound = StreamErrorCode(16); + + /// `17` – The caller lacks permission for this operation. + static const notAllowed = StreamErrorCode(17); + + /// `18` – The event type is not supported. + static const eventNotSupported = StreamErrorCode(18); + + /// `19` – The channel does not support this feature. + static const channelFeatureNotSupported = StreamErrorCode(19); + + /// `20` – The message is longer than the allowed maximum. + static const messageTooLong = StreamErrorCode(20); + + /// `21` – Threads cannot be nested further. + static const multipleNestingLevel = StreamErrorCode(21); + + /// `22` – The request payload is too big. + static const payloadTooBig = StreamErrorCode(22); + + /// `40` – The token has expired, or has been revoked. Either way a fresh + /// token fixes it. + static const tokenExpired = StreamErrorCode(40); + + /// `41` – The token is not valid yet (its `nbf` claim is in the future). + /// Waiting fixes it. + static const tokenNotValidYet = StreamErrorCode(41); + + /// `42` – The token was used before it was issued (its `iat` claim is in + /// the future). Waiting fixes it. + static const tokenUsedBeforeIssuedAt = StreamErrorCode(42); + + /// `43` – The token's signature cannot be accepted. A configuration + /// problem no token or wait fixes. + static const tokenSignatureInvalid = StreamErrorCode(43); + + /// `44` – The custom command has no endpoint configured. + static const customCommandEndpointMissing = StreamErrorCode(44); + + /// `45` – Calling the custom command endpoint failed. + static const customCommandEndpointCallError = StreamErrorCode(45); + + /// `46` – The connection id is not known to the server. + static const connectionIdNotFound = StreamErrorCode(46); + + /// `48` – The server timed the request out. + static const requestTimeout = StreamErrorCode(48); + + /// `60` – The user must wait out the channel's cooldown before sending + /// another message. + static const cooldown = StreamErrorCode(60); + + /// `70` – Channels matching the query were withheld because the user lacks + /// access to them. + static const queryChannelPermissionsMismatch = StreamErrorCode(70); + + /// `71` – The client has too many concurrent connections. + static const tooManyConnections = StreamErrorCode(71); + + /// `72` – The operation is not supported in push v1. + static const notSupportedInPushV1 = StreamErrorCode(72); + + /// `73` – Message moderation failed, or the moderation provider failed. + static const moderationFailed = StreamErrorCode(73); + + /// `80` – No video provider is configured. + static const videoProviderNotConfigured = StreamErrorCode(80); + + /// `81` – The call id is not valid. + static const videoInvalidCallId = StreamErrorCode(81); + + /// `82` – Creating the call failed. + static const videoCreateCallFailed = StreamErrorCode(82); + + /// `99` – The app is suspended. + static const appSuspended = StreamErrorCode(99); + + /// `100` – No video datacenters are available. + static const videoNoDatacentersAvailable = StreamErrorCode(100); + + /// `101` – Joining the call failed. + static const videoJoinCallFailure = StreamErrorCode(101); + + /// `102` – Calls matching the query were withheld because the user lacks + /// access to them. + static const queryCallsPermissionsMismatch = StreamErrorCode(102); + + /// `103` – The call being accepted or rejected is gone. + static const acceptRejectCallIsGone = StreamErrorCode(103); + + /// `104` – Call stats matching the query were withheld because the user + /// lacks access to them. + static const queryCallStatsPermissionsMismatch = StreamErrorCode(104); + + /// `105` – The operation is supported only in push v3. + static const supportedInPushV3 = StreamErrorCode(105); + + /// `106` – The call is restricted in the caller's region. + static const videoRestrictedRegion = StreamErrorCode(106); + + /// `107` – The product is suspended for the app. + static const productSuspended = StreamErrorCode(107); + + /// `108` – The caller cancelled the request before the server finished. + static const requestCancelled = StreamErrorCode(108); + + /// `109` – Joining this call requires requesting end-to-end encryption. + static const videoJoinMustRequestE2ee = StreamErrorCode(109); + + /// `110` – End-to-end encryption is not available for this call. + static const videoJoinE2eeNotAvailable = StreamErrorCode(110); + + /// `111` – The moderation service is overloaded. + static const moderationOverloaded = StreamErrorCode(111); + + /// `112` – Feeds storage is unavailable. + static const feedsStorageUnavailable = StreamErrorCode(112); + + /// `113` – Some of the users named by the request do not exist. + /// + /// Distinct from [notFound] so the missing ids can be read from the error's + /// `exception_fields` and created before trying again. + static const usersNotFound = StreamErrorCode(113); +} + +/// Convenience predicates grouping the codes that share a remedy. +extension StreamErrorCodePredicates on StreamErrorCode { + /// Whether this code says the token has expired + /// ([StreamErrorCode.tokenExpired]). + /// + /// A fresh token fixes it. + bool get isTokenExpired => this == .tokenExpired; + + /// Whether this code says the token is not valid yet + /// ([StreamErrorCode.tokenNotValidYet] and + /// [StreamErrorCode.tokenUsedBeforeIssuedAt]). + /// + /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes + /// it, a fresh token minted by the same skewed clock does not. + bool get isTokenNotYetValid => this == .tokenNotValidYet || this == .tokenUsedBeforeIssuedAt; + + /// Whether this code says the token's signature cannot be accepted + /// ([StreamErrorCode.tokenSignatureInvalid]). + /// + /// A configuration problem — signed with the wrong secret. Neither waiting + /// nor a fresh token from the same signer fixes it. + bool get isTokenSignatureInvalid => this == .tokenSignatureInvalid; + + /// Whether this code says the API key cannot be accepted + /// ([StreamErrorCode.apiKeyInvalid]). + /// + /// The key is unknown, or the product it addresses is not enabled for the + /// app. A configuration problem no token fixes. + bool get isApiKeyInvalid => this == .apiKeyInvalid; +} diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart new file mode 100644 index 00000000..671556bb --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -0,0 +1,297 @@ +import 'package:equatable/equatable.dart'; + +import '../utils/object.dart'; +import 'stream_api_error.dart'; +import 'stream_error_code.dart'; + +/// The root of every failure a Stream SDK reports. +/// +/// There are exactly four kinds, named for what the caller should do about +/// them: +/// +/// - [StreamApiException] — the server answered, and the answer was an error. +/// - [StreamNetworkException] — the server was never heard from; the outcome +/// of the request is unknown. +/// - [StreamAuthenticationException] — credentials could not be produced or +/// sent. +/// - [StreamClientException] — the SDK itself failed. +/// +/// The root is sealed, so a `switch` over the four kinds is exhaustive. +/// Product SDKs extend a kind (`StreamChatApiException extends +/// StreamApiException`) rather than adding a fifth. +/// +/// Programmer mistakes are not part of this hierarchy: misusing the SDK — +/// sending before connecting, using a disposed client — throws Dart's own +/// [StateError] or [ArgumentError], which signal a programming error rather +/// than a condition to handle at runtime. +sealed class StreamException extends Equatable implements Exception { + /// Creates a [StreamException]. + const StreamException({ + required this.message, + this.cause, + }); + + /// The [StreamException] that [error] represents, or `null` when it does + /// not represent one. + /// + /// Kept as it is when [error] is one already, and read out of a server + /// error payload when it is a [StreamApiError]. A boundary supplies its own + /// fallback for everything else: + /// + /// ```dart + /// final exception = StreamException.tryFrom(error) ?? + /// StreamNetworkException(message: 'Failed to open the connection', cause: error); + /// ``` + static StreamException? tryFrom(Object? error) => switch (error) { + final StreamException exception => exception, + final StreamApiError apiError => StreamApiException.fromApiError(apiError), + _ => null, + }; + + /// What went wrong. + /// + /// Always present and developer-readable, but not localized and possibly + /// carrying server-internal detail. For user-facing UI, consider keying + /// localized strings off [StreamApiException.code] rather than displaying + /// this verbatim. + final String message; + + /// The failure underneath this one, when this exception wraps another. + final Object? cause; + + @override + List get props => [message, cause]; + + @override + String toString() { + final name = objectRuntimeType(this, 'StreamException'); + final buffer = StringBuffer('$name: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// A request that reached a Stream server and was answered with an error. +/// +/// The server's verdict is final for this attempt: the request was received, +/// understood, and rejected. What to do next is described by the facts +/// carried here — [code], [statusCode], [unrecoverable], [retryAfter] — not +/// by the transport that delivered them: a WebSocket connection refused by +/// the server reports the same exception as a rejected REST call. +base class StreamApiException extends StreamException { + /// Creates a [StreamApiException]. + const StreamApiException({ + required super.message, + required this.statusCode, + this.code, + this.moreInfo, + this.unrecoverable = false, + this.retryAfter, + this.apiError, + super.cause, + }); + + /// Creates a [StreamApiException] from the server's error payload. + factory StreamApiException.fromApiError( + StreamApiError error, { + Duration? retryAfter, + Object? cause, + }) { + return StreamApiException( + message: error.message, + statusCode: error.statusCode, + code: error.code, + moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, + unrecoverable: error.unrecoverable ?? false, + retryAfter: retryAfter, + apiError: error, + cause: cause, + ); + } + + /// The HTTP status the server answered with. + /// + /// Independent of [code]: the same code can arrive with different statuses, + /// so neither can be derived from the other. + final int statusCode; + + /// Stream's stable error code. + /// + /// The machine-readable discriminator — the value to branch on, where + /// [message] is not stable. [StreamErrorCode] names the known values; + /// a code without a named constant still carries its number. + /// + /// `null` when the response carried no Stream error payload, as when an + /// intermediary answered with an error of its own. + final StreamErrorCode? code; + + /// A documentation URL for this error, when the server sent one. + /// + /// Most errors carry none; a refused connection is the likeliest to. + final String? moreInfo; + + /// Whether the server declared that retrying will not help. + /// + /// Worth consulting on any product: a permission denial carries it, as do + /// some Video errors. Authoritative when `true`; `false` only means the + /// server said nothing — it must not be read as "retrying will help". + final bool unrecoverable; + + /// How long the server asked to wait before retrying, when it named a wait. + /// + /// Rate-limited requests can carry one; errors reported over a WebSocket + /// never do. + final Duration? retryAfter; + + /// The server's error payload, when the failure carried a parseable one. + /// + /// `null` when only a bare status was available, as when an intermediary + /// answered with an error of its own. + final StreamApiError? apiError; + + /// Whether the token this request carried has expired + /// ([StreamErrorCode.tokenExpired]). + /// + /// A freshly issued token fixes it. The SDK refreshes expired tokens + /// automatically, so this surfaces only when a refresh could not help. + bool get isTokenExpired => code?.isTokenExpired ?? false; + + /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] and + /// [StreamErrorCode.tokenUsedBeforeIssuedAt]). + /// + /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes + /// it, a fresh token minted by the same skewed clock does not. + bool get isTokenNotYetValid => code?.isTokenNotYetValid ?? false; + + /// Whether the token's signature cannot be accepted + /// ([StreamErrorCode.tokenSignatureInvalid]). + /// + /// A configuration problem — signed with the wrong secret. Neither waiting + /// nor a fresh token from the same signer fixes it. + bool get isTokenSignatureInvalid => code?.isTokenSignatureInvalid ?? false; + + /// Whether the API key cannot be accepted + /// ([StreamErrorCode.apiKeyInvalid]). + /// + /// The key is unknown, or the product it addresses is not enabled for the + /// app. A configuration problem no token fixes. + bool get isApiKeyInvalid => code?.isApiKeyInvalid ?? false; + + /// Whether the request was rate limited (HTTP 429). + /// + /// [retryAfter] carries the server's suggested wait when one was sent. + bool get isRateLimited => statusCode == 429; + + @override + List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter, apiError]; + + @override + String toString() { + final facts = [ + if (code case final code?) 'code: $code', + 'statusCode: $statusCode', + if (unrecoverable) 'unrecoverable', + if (retryAfter case final retryAfter?) 'retryAfter: ${retryAfter.inSeconds}s', + ]; + + final name = objectRuntimeType(this, 'StreamApiException'); + final buffer = StringBuffer('$name(${facts.join(', ')}): $message'); + if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// A request or connection that never got a verdict from the server. +/// +/// The outcome is **unknown**: the server may have received and performed the +/// operation before the connection failed. Consider retrying a write only +/// through an idempotent path. +base class StreamNetworkException extends StreamException { + /// Creates a [StreamNetworkException]. + const StreamNetworkException({ + required super.message, + this.isCancelled = false, + this.isTimeout = false, + this.closeCode, + super.cause, + }); + + /// Whether the caller cancelled the request. + /// + /// A cancellation is usually the app navigating away — something to ignore, + /// not to surface. + final bool isCancelled; + + /// Whether the request timed out before the server answered. + final bool isTimeout; + + /// The WebSocket close code, when the failure was a socket closure. + /// + /// Carries little signal on its own: a connection can close with a normal + /// code even when something went wrong, with the reason reported separately + /// as a [StreamApiException]. The exception kind and its code are the + /// reliable classifiers; the close code is not. + final int? closeCode; + + @override + List get props => [...super.props, isCancelled, isTimeout, closeCode]; + + @override + String toString() { + final name = objectRuntimeType(this, 'StreamNetworkException'); + final closure = switch (closeCode) { + final closeCode? => '(closeCode: $closeCode)', + _ => '', + }; + + final buffer = StringBuffer('$name$closure: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// Credentials that could not be produced or sent. +/// +/// Raised before anything reaches the server: the token provider failed (its +/// error is preserved in [cause]), no user is configured, or the WebSocket +/// authentication message could not go out. A server that *rejected* +/// credentials has answered — that is a [StreamApiException], see +/// [StreamApiException.isTokenExpired] and its siblings. +base class StreamAuthenticationException extends StreamException { + /// Creates a [StreamAuthenticationException]. + const StreamAuthenticationException({ + required super.message, + super.cause, + }); + + @override + String toString() { + final name = objectRuntimeType(this, 'StreamAuthenticationException'); + final buffer = StringBuffer('$name: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// A failure inside the SDK itself. +/// +/// Wire data that would not decode, an invariant that did not hold, or an +/// error thrown by app-supplied code the SDK ran on the caller's behalf. Not +/// the end user's problem — worth reporting to a crash tracker rather than +/// showing in UI. +base class StreamClientException extends StreamException { + /// Creates a [StreamClientException]. + const StreamClientException({ + required super.message, + super.cause, + }); + + @override + String toString() { + final name = objectRuntimeType(this, 'StreamClientException'); + final buffer = StringBuffer('$name: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 93f92c81..cdfc3bac 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,5 +1,11 @@ -import '../errors/client_exception.dart'; +import 'dart:async'; + +import 'package:dio/dio.dart'; + +import '../api/stream_core_dio_exception.dart'; +import '../errors/stream_exception.dart'; import '../utils/in_flight_cache.dart'; +import '../utils/result.dart'; import 'token_provider.dart'; import 'user_token.dart'; @@ -142,9 +148,12 @@ class TokenManager { /// one those invalidated, so a provider that never returns cannot hold up a caller for the /// identity that replaced it. /// - /// Fails with a [ClientException] when no identity is configured, or when [reset] runs while the - /// token is loading, and with an [ArgumentError] when the provider returns a token that does not - /// belong to the user it was loading for. + /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs + /// while the token is loading, and when the [TokenProvider] returns a token that does not belong + /// to the user it was loading for. + /// + /// A failing [TokenProvider] fails this too, with the kind its own failure named — see + /// [TokenProvider.loadToken]. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; @@ -163,17 +172,23 @@ class TokenManager { Future _loadAndNotify() async { final identity = _identity; if (identity == null) { - throw ClientException(message: 'No user is configured, call setTokenProvider before loading a token'); + throw const StreamAuthenticationException( + message: 'No user is configured, call setTokenProvider before loading a token', + ); } final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider.loadToken(loadingFor); + final updatedToken = await _loadFrom(identity.provider, loadingFor); // Both built-in providers check this, but a custom one need not: caching another user's token // would authenticate every later request as them. if (updatedToken.userId != loadingFor) { - throw ArgumentError('User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"'); + throw StreamAuthenticationException( + message: + 'The token provider returned a token for user "${updatedToken.userId}" ' + 'while loading one for user "$loadingFor"', + ); } // `setTokenProvider` or `expireToken` may have run while this loaded, in which case the token @@ -182,7 +197,7 @@ class TokenManager { // After a `reset` the user is gone, so the token is not returned. After a switch it is: the // caller that started as this user may finish as them. if (_identity == null) { - throw ClientException(message: 'The user was reset while its token was loading'); + throw const StreamAuthenticationException(message: 'The user was reset while its token was loading'); } return updatedToken; @@ -194,6 +209,29 @@ class TokenManager { return updatedToken; } + Future _loadFrom(TokenProvider provider, String userId) async { + final result = await runSafely(() => provider.loadToken(userId)); + + return result.getOrElse((error, stackTrace) { + var exception = StreamException.tryFrom(error); + exception ??= switch (error) { + DioException() => error.toStreamException(), + TimeoutException() => StreamNetworkException( + message: 'The token provider timed out loading a token for user "$userId"', + isTimeout: true, + cause: error, + ), + _ => StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: error, + ), + }; + + // The provider's own trace, not this line's. + Error.throwWithStackTrace(exception, stackTrace ?? StackTrace.current); + }); + } + /// Expires the currently cached token. /// /// Clears the cached token, forcing the next call to [getToken] to diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 938ef2cc..c52c0a4f 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import '../errors/stream_exception.dart'; import 'user_token.dart'; /// A provider for loading user authentication tokens. @@ -44,6 +47,11 @@ abstract interface class TokenProvider { /// /// Throws an [ArgumentError] if the token does not belong to [userId], or if /// it is not valid for the provider's authentication type. + /// + /// What a failure throws decides whether the connection is retried: a + /// [StreamNetworkException] or [TimeoutException] is about the moment and + /// retries, a `DioException` is read for what it carries, and anything else + /// blames the credentials and does not. Future loadToken(String userId); } diff --git a/packages/stream_core/lib/src/utils.dart b/packages/stream_core/lib/src/utils.dart index eb10d51f..c7933448 100644 --- a/packages/stream_core/lib/src/utils.dart +++ b/packages/stream_core/lib/src/utils.dart @@ -6,6 +6,7 @@ export 'utils/in_flight_cache.dart'; export 'utils/lifecycle_state_provider.dart'; export 'utils/list_extensions.dart'; export 'utils/network_state_provider.dart'; +export 'utils/object.dart'; export 'utils/result.dart'; export 'utils/shared_emitter.dart'; export 'utils/standard.dart'; diff --git a/packages/stream_core/lib/src/utils/object.dart b/packages/stream_core/lib/src/utils/object.dart new file mode 100644 index 00000000..b9668ac2 --- /dev/null +++ b/packages/stream_core/lib/src/utils/object.dart @@ -0,0 +1,17 @@ +/// A [Object.runtimeType] that is constant in release mode. +/// +/// Returns the runtime type of [object] in debug mode, and [optimizedValue] +/// in release mode, where type names may be minified and reading them +/// prevents the compiler from discarding type information. +/// +/// Mirrors Flutter's `objectRuntimeType`, so `toString` implementations can +/// name their exact type where it helps — a debug log — and a stable name +/// where it would not. +String objectRuntimeType(Object? object, String optimizedValue) { + var value = optimizedValue; + assert(() { + value = object.runtimeType.toString(); + return true; + }()); + return value; +} diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index aa0742b8..386b3ea9 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -1,12 +1,8 @@ import 'dart:typed_data'; -import 'package:equatable/equatable.dart'; - -import '../../../errors.dart'; import '../../../utils.dart'; import 'web_socket_options.dart'; -export 'dart:typed_data'; export 'web_socket_options.dart'; /// Interface for WebSocket engine implementations. @@ -180,26 +176,3 @@ extension type const CloseCode(int code) implements int { /// This **must not** be set explicitly by an endpoint. static const tlsHandshakeFailure = CloseCode(1015); } - -class WebSocketEngineException extends Equatable implements Exception { - const WebSocketEngineException({ - String? reason, - int? code = 0, - this.error, - }) : reason = reason ?? 'Unknown', - code = code ?? 0; - - final String reason; - final int code; - final Object? error; - - /// Returns the error as a StreamApiError if it is of that type or - /// null otherwise. - StreamApiError? get apiError { - if (error case final StreamApiError error) return error; - return null; - } - - @override - List get props => [reason, code, error]; -} diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 6dc268ae..3ac62208 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -36,7 +36,7 @@ import 'retry_strategy.dart'; /// final recoveryHandler = ConnectionRecoveryHandler( /// client: client, /// networkStateProvider: NetworkStateProvider(), -/// appLifecycleStateProvider: AppLifecycleStateProvider(), +/// lifecycleStateProvider: myLifecycleStateProvider, /// ); /// ``` class ConnectionRecoveryHandler extends Disposable { diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index accc23f8..b22adb9f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../errors.dart'; import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; @@ -90,9 +91,16 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, send: send, authenticator: onAuthenticate, tag: '$tag:Auth', - onFailure: (error) => disconnect( - source: .authenticationFailed(error: error), - ), + onFailure: (error, stackTrace) { + var exception = StreamException.tryFrom(error); + exception ??= StreamAuthenticationException( + message: 'The connection could not be authenticated', + cause: error, + ); + + final source = DisconnectionSource.authenticationFailed(error: exception, stackTrace: stackTrace); + unawaited(disconnect(source: source)); + }, ); } @@ -154,8 +162,34 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// /// The [request] is encoded using the configured message codec and sent to the server. /// - /// Returns a [Result] indicating success or failure of the send operation. - Result send(WsRequest request) => _engine.sendMessage(request); + /// Returns a [Result] whose failure carries a [StreamNetworkException] when + /// the connection dropped, and a [StreamClientException] otherwise. + /// + /// Throws a [StateError] when this client has not been connected. + Result send(WsRequest request) { + if (connectionState.value case Initialized()) { + throw StateError('The connection has not been opened. Call connect() first.'); + } + + final result = _engine.sendMessage(request); + if (result case Failure(:final error, :final stackTrace)) { + var exception = StreamException.tryFrom(error); + exception ??= switch (error) { + StateError() => StreamNetworkException( + message: 'The connection dropped before the request went out', + cause: error, + ), + _ => StreamClientException( + message: 'The request could not be sent', + cause: error, + ), + }; + + return Result.failure(exception, stackTrace); + } + + return result; + } /// Establishes a WebSocket connection. /// @@ -192,11 +226,16 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Handed to `disconnect`, which reports the reason, closes the socket, and records the closure // even when the close fails. Returned, so a caller connecting again is not refused for the race. - return result.getOrElse( - (error, _) => disconnect( - source: .serverInitiated(error: .new(error: error)), - ), - ); + if (result case Failure(:final error, :final stackTrace)) { + var exception = StreamException.tryFrom(error); + exception ??= StreamNetworkException( + message: 'Failed to open the connection to ${options.url}', + cause: error, + ); + + final source = DisconnectionSource.serverInitiated(error: exception, stackTrace: stackTrace); + return disconnect(source: source); + } } /// Closes the WebSocket connection. @@ -252,7 +291,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Any active state that wasn’t user/system initiated becomes server initiated. Connecting() || Authenticating() || Connected() => ServerInitiated( - error: WebSocketEngineException(code: closeCode, reason: closeReason), + error: StreamNetworkException( + message: closeReason ?? 'The connection was closed unexpectedly', + closeCode: closeCode, + ), ), // Not meaningful to transition from these. @@ -270,13 +312,16 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void onError(Object error, [StackTrace? stackTrace]) { _logger.e(() => 'socket failed', error: error, stackTrace: stackTrace); - final source = ServerInitiated( - error: WebSocketEngineException(error: error), + var exception = StreamException.tryFrom(error); + exception ??= StreamNetworkException( + message: 'The connection reported an error', + cause: error, ); // Update the connection state to 'disconnecting' with the source. // // The socket closes itself after an error, so the closure that follows records the disconnection. + final source = ServerInitiated(error: exception, stackTrace: stackTrace); _connectionState = WebSocketConnectionState.disconnecting(source: source); } @@ -299,10 +344,13 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - final source = ServerInitiated( - error: WebSocketEngineException(error: error), + var exception = StreamException.tryFrom(error); + exception ??= StreamClientException( + message: 'The server reported an error the client could not interpret', + cause: error, ); + final source = ServerInitiated(error: exception); return unawaited(disconnect(source: source)); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 6f4270ec..f8ea31b4 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -1,4 +1,4 @@ -import '../../errors.dart' show StreamApiError; +import '../../errors.dart' show StreamApiException, StreamNetworkException; import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_request.dart'; @@ -19,9 +19,10 @@ typedef WsRequestSender = Result Function(WsRequest request); /// attempt after a refusal sees it. Use it to replace credentials that were refused. /// /// Throw when the credentials did not go out, whether because sending failed or because this -/// function chose not to send them. The connection is then closed with [AuthenticationFailed], and -/// is not reconnected. -typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiError? previousError); +/// function chose not to send them. The connection is then closed with [AuthenticationFailed] +/// carrying what was thrown, and reconnected only when that says the network was at fault rather +/// than the credentials — so pass a failed [WsRequestSender]'s error on rather than replacing it. +typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiException? previousError); /// A handler that authenticates newly opened connections and remembers why the server refused the /// last one. @@ -42,7 +43,7 @@ class WebSocketAuthenticationHandler { final WebSocketAuthenticator? _authenticator; final WsRequestSender _send; - final void Function(Object error) _onFailure; + final void Function(Object error, StackTrace? stackTrace) _onFailure; // Identifies the attempt in flight: an authenticator can outlive the one that started it. var _attempt = 0; @@ -51,8 +52,8 @@ class WebSocketAuthenticationHandler { /// /// Becomes null once the attempt that read it finishes, or once a connection is established. An /// attempt abandoned before it finishes leaves it behind, for the attempt that replaces it. - StreamApiError? get previousError => _previousError; - StreamApiError? _previousError; + StreamApiException? get previousError => _previousError; + StreamApiException? _previousError; /// Takes in a connection state change. /// @@ -67,8 +68,9 @@ class WebSocketAuthenticationHandler { Connected() => null, // The caller took control; what they connect with next may have nothing to do with the refusal. Disconnected(source: UserInitiated()) => null, - // The server closed without sending an error, so the last one still applies. - Disconnected(source: ServerInitiated(:final error)) => error?.apiError ?? _previousError, + // The server closed without sending an error, so the last one still applies. Only a verdict + // counts: a transport failure says nothing about the credentials. + Disconnected(source: ServerInitiated(:final StreamApiException error)) => error, _ => _previousError, }; } @@ -103,7 +105,7 @@ class WebSocketAuthenticationHandler { if (result case Failure(:final error, :final stackTrace)) { _logger.w(() => 'attempt #$attempt could not be authenticated', error: error, stackTrace: stackTrace); - return _onFailure(error); + return _onFailure(error, stackTrace); } } @@ -111,7 +113,9 @@ class WebSocketAuthenticationHandler { WsRequestSender _senderFor(int attempt) => (request) { if (attempt == _attempt) return _send(request); - final error = StateError('Connection attempt was abandoned before its credentials were sent'); - return Result.failure(error); + const error = StreamNetworkException( + message: 'The connection attempt was abandoned before its credentials were sent', + ); + return const Result.failure(error); }; } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 2ccdc1ce..a80b246b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; -import '../../errors.dart' show StreamApiErrorExtension; +import '../../errors.dart'; +import '../../user/token_provider.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; import 'engine/web_socket_engine.dart'; @@ -216,7 +217,8 @@ sealed class DisconnectionSource extends Equatable { /// Indicates that the server closed the connection, optionally with error details. /// Reconnection eligibility depends on the specific error type. const factory DisconnectionSource.serverInitiated({ - WebSocketEngineException? error, + StreamException? error, + StackTrace? stackTrace, }) = ServerInitiated; /// Creates a [SystemInitiated] disconnection source. @@ -241,7 +243,10 @@ sealed class DisconnectionSource extends Equatable { /// /// Indicates that the connection opened but could not be authenticated, so it /// was closed without ever being usable. - const factory DisconnectionSource.authenticationFailed({Object? error}) = AuthenticationFailed; + const factory DisconnectionSource.authenticationFailed({ + StreamException? error, + StackTrace? stackTrace, + }) = AuthenticationFailed; /// A human-readable description of the disconnection source. /// @@ -260,10 +265,11 @@ sealed class DisconnectionSource extends Equatable { /// What closed the connection, or `null` when this source carries no cause. /// - /// For a [ServerInitiated] closure this is the error that was reported, or a - /// [WebSocketEngineException] describing the close when nothing else was. + /// For a [ServerInitiated] closure this is the [StreamException] that was + /// reported; for an [AuthenticationFailed] one, whatever prevented the + /// credentials from going out. Object? get cause => switch (this) { - ServerInitiated(:final error) => error?.error ?? error, + ServerInitiated(:final error) => error, AuthenticationFailed(:final error) => error, UserInitiated() || SystemInitiated() || UnHealthyConnection() || ConnectTimeout() => null, }; @@ -272,11 +278,23 @@ sealed class DisconnectionSource extends Equatable { /// /// {@template webSocketReconnectionRules} /// - [UserInitiated] — no, the caller asked for the connection to close. - /// - [AuthenticationFailed] — no, credentials that never went out will not go out on a retry. - /// - [ServerInitiated] — no for a deliberate close (code 1000), for a token error a fresh token - /// would not fix, and for a client error that is neither a rate limit nor an expired token. Yes - /// otherwise, including an expired token and a rate limit, which a later attempt can get past. + /// - [AuthenticationFailed] — no, with or without an error: credentials that could not be + /// produced or sent will not fare better on a retry. The exception is a non-cancelled + /// [StreamNetworkException], which indicts the moment rather than the credentials — see + /// [TokenProvider.loadToken]. /// - [SystemInitiated], [UnHealthyConnection], [ConnectTimeout] — yes. + /// - [ServerInitiated] — decided by the error it carries: + /// - no error — yes, the closure said nothing against trying again. + /// - a server verdict ([StreamApiException]) — no when the server said retrying will not help + /// ([StreamApiException.unrecoverable]), when the token's signature or the API key is refused (configuration a + /// retry reproduces), or for any other 4xx. Yes for an expired token (the reconnect + /// authenticates with a fresh one), a token not valid yet (clock skew a later attempt can get + /// past), a rate limit, and 5xx. + /// - a transport failure ([StreamNetworkException]) — yes, except a bare normal closure + /// (code 1000) with no error event before it, which is the server deliberately ending the + /// session. + /// - anything else — yes for an SDK-side failure, no for credentials that could not be sent + /// (a retry changes nothing). /// /// Necessary, but not on its own sufficient. Whether a reconnection is then actually made is /// decided by `ConnectionRecoveryHandler`, which recovers only a connection that was established, @@ -284,17 +302,32 @@ sealed class DisconnectionSource extends Equatable { /// out stays down, where one that times out on the way back does not. /// {@endtemplate} bool get isReconnectable => switch (this) { - ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, - ServerInitiated(:final error) => switch (error?.apiError) { - final it? when it.isInvalidTokenError => false, - final it? when it.isClientError && !it.isRateLimitError && !it.isTokenExpiredError => false, - _ => true, // Reconnect on other server initiated disconnections + UserInitiated() => false, + // Credentials that could not be produced or sent will not fare better on + // a retry — unless what stopped them was the network itself, which is + // about the moment, not the credentials. + AuthenticationFailed(:final error) => switch (error) { + StreamNetworkException(isCancelled: true) => false, + StreamNetworkException() => true, + _ => false, }, - UnHealthyConnection() => true, SystemInitiated() => true, + UnHealthyConnection() => true, ConnectTimeout() => true, - UserInitiated() => false, - AuthenticationFailed() => false, + ServerInitiated(:final error) => switch (error) { + StreamApiException(unrecoverable: true) => false, + StreamApiException(isTokenSignatureInvalid: true) => false, + StreamApiException(isApiKeyInvalid: true) => false, + StreamApiException(isTokenExpired: true) => true, + StreamApiException(isTokenNotYetValid: true) => true, + StreamApiException(isRateLimited: true) => true, + StreamApiException(:final statusCode) => statusCode < 400 || statusCode >= 500, + StreamNetworkException(closeCode: CloseCode.normalClosure) => false, + StreamNetworkException() => true, + StreamAuthenticationException() => false, + StreamClientException() => true, + null => true, + }, }; @override @@ -318,14 +351,17 @@ final class UserInitiated extends DisconnectionSource { /// provides additional context about the disconnection cause. final class ServerInitiated extends DisconnectionSource { /// Creates a [ServerInitiated] disconnection source. - const ServerInitiated({this.error}); + const ServerInitiated({this.error, this.stackTrace}); /// The error that caused the server to close the connection. /// - /// When present, contains details about the server error that led to - /// disconnection. This can include authentication failures, protocol - /// violations, or other server-side issues. - final WebSocketEngineException? error; + /// A [StreamApiException] when the server reported why — the same payload a + /// rejected REST call carries — and a [StreamNetworkException] describing + /// the closure when it did not. + final StreamException? error; + + /// Where [error] was raised, when it was raised rather than read off the wire. + final StackTrace? stackTrace; @override List get props => [error]; @@ -367,10 +403,16 @@ final class ConnectTimeout extends DisconnectionSource { /// error event instead. final class AuthenticationFailed extends DisconnectionSource { /// Creates an [AuthenticationFailed] disconnection source. - const AuthenticationFailed({this.error}); + const AuthenticationFailed({this.error, this.stackTrace}); /// The error that prevented the connection from authenticating. - final Object? error; + /// + /// Usually a [StreamAuthenticationException] whose [StreamException.cause] + /// is whatever the authenticator threw. + final StreamException? error; + + /// Where [error] was raised. + final StackTrace? stackTrace; @override List get props => [error]; diff --git a/packages/stream_core/lib/stream_core.dart b/packages/stream_core/lib/stream_core.dart index 8e7d86b0..33dfe3af 100644 --- a/packages/stream_core/lib/stream_core.dart +++ b/packages/stream_core/lib/stream_core.dart @@ -7,5 +7,5 @@ export 'src/logger.dart'; export 'src/platform.dart'; export 'src/query.dart'; export 'src/user.dart'; -export 'src/utils.dart' hide SharedEmitterImpl, StateEmitterImpl; +export 'src/utils.dart' hide SharedEmitterImpl, StateEmitterImpl, objectRuntimeType; export 'src/ws.dart'; diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index f2758bd7..f6c12dd9 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -230,9 +231,9 @@ void main() { isA().having( (it) => it.error, 'error', - isA() - .having((it) => it.message, 'message', 'Failed to load auth token') - .having((it) => it.underlyingError, 'underlyingError', isStateError), + // The token manager reports the load failure itself; the + // interceptor passes its report through untouched. + isA().having((it) => it.cause, 'cause', isStateError), ), ), ); diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart index 7eed8dfb..e93f4dce 100644 --- a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; diff --git a/packages/stream_core/test/api/stream_core_dio_error_test.dart b/packages/stream_core/test/api/stream_core_dio_error_test.dart deleted file mode 100644 index d07fcbd1..00000000 --- a/packages/stream_core/test/api/stream_core_dio_error_test.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -/// The body the API returns when it refuses a request. -Map _errorBody({int code = 40, int statusCode = 401, String message = 'token expired'}) => { - 'code': code, - 'details': [], - 'duration': '0ms', - 'message': message, - 'more_info': '', - 'StatusCode': statusCode, -}; - -DioException _failure({ - Object? body, - int? statusCode, - String? statusMessage, - String? message, - DioExceptionType type = DioExceptionType.badResponse, -}) { - final options = RequestOptions(path: '/test'); - return DioException( - requestOptions: options, - type: type, - message: message, - response: statusCode == null - ? null - : Response( - requestOptions: options, - statusCode: statusCode, - statusMessage: statusMessage, - data: body, - ), - ); -} - -void main() { - group('DioException.apiError', () { - test('reads the Stream error from a decoded body', () { - final error = _failure(body: _errorBody(), statusCode: 401).apiError; - - expect(error?.code, 40); - expect(error?.statusCode, 401); - }); - - test('reads the Stream error from a body the server sent as text', () { - // Without a JSON content type Dio hands the body over as a string, and the refusal is the - // same one either way. - final error = _failure(body: jsonEncode(_errorBody()), statusCode: 401).apiError; - - expect(error?.code, 40); - }); - - test('reads null from a body that is not a Stream error', () { - // A proxy or gateway can answer with JSON of its own, which must not throw on the way out. - expect(_failure(body: {'error': 'gateway timeout'}, statusCode: 504).apiError, isNull); - expect(_failure(body: 'not json at all', statusCode: 502).apiError, isNull); - expect(_failure(statusCode: 500).apiError, isNull); - expect(_failure().apiError, isNull); - }); - }); - - group('DioException.toClientException', () { - test('takes its message, status code and cause from the Stream error', () { - // The error and the response deliberately disagree, so each assertion says which one won. - // Given the same values, either source would satisfy them. - final exception = _failure( - body: _errorBody(statusCode: 429), - statusCode: 500, - statusMessage: 'Internal Server Error', - ).toClientException(); - - // The API's own account of the failure says more than the transport's, so it wins. - expect(exception.message, 'token expired'); - expect(exception.statusCode, 429); - expect(exception.apiError?.code, 40); - }); - - test('falls back to what the transport reported when the response carried no Stream error', () { - final dioException = _failure( - body: {'error': 'gateway timeout'}, - statusCode: 504, - statusMessage: 'Gateway Timeout', - ); - - final exception = dioException.toClientException(); - - expect(exception.message, 'Gateway Timeout'); - expect(exception.statusCode, 504); - // Nothing better to blame, so the transport failure is the cause. - expect(exception.underlyingError, same(dioException)); - expect(exception.apiError, isNull); - }); - - test('falls back to the exception message when there is no response at all', () { - final exception = _failure(message: 'connection refused').toClientException(); - - expect(exception.message, 'connection refused'); - expect(exception.statusCode, isNull); - }); - - test('never leaves the message null, so a caller always has something to show', () { - final exception = _failure().toClientException(); - - expect(exception.message, isEmpty); - }); - - test('marks a request the caller cancelled as such', () { - final cancelled = _failure(type: DioExceptionType.cancel).toClientException(); - final refused = _failure(body: _errorBody(), statusCode: 401).toClientException(); - - // A caller that called the request off should not be shown it as a failure. - expect(cancelled.isRequestCancelledError, isTrue); - expect(refused.isRequestCancelledError, isFalse); - }); - }); -} diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart new file mode 100644 index 00000000..8d266d0e --- /dev/null +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -0,0 +1,354 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +/// The body the API returns when it refuses a request. +Map _errorBody({ + int code = 40, + int statusCode = 401, + String message = 'token expired', + Object? details = const [], +}) => { + 'code': code, + 'details': details, + 'duration': '0ms', + 'message': message, + 'more_info': '', + 'StatusCode': statusCode, +}; + +DioException _failure({ + Object? body, + int? statusCode, + String? statusMessage, + String? message, + Map>? headers, + DioExceptionType type = DioExceptionType.badResponse, +}) { + final options = RequestOptions(path: '/test'); + return DioException( + requestOptions: options, + type: type, + message: message, + response: statusCode == null + ? null + : Response( + requestOptions: options, + statusCode: statusCode, + statusMessage: statusMessage, + headers: Headers.fromMap(headers ?? const {}), + data: body, + ), + ); +} + +void main() { + group('DioException.toStreamException', () { + test('takes the status from the payload, and the wait from the headers', () { + // The two deliberately disagree, so each assertion says which one won. + // Reading the status off the response instead would leave a rate limit + // behind an edge's 500 reporting as neither rate limited nor 4xx. + final exception = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'rate limited'), + statusCode: 500, + headers: const { + 'retry-after': ['30'], + }, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.statusCode, 'statusCode', 429) + .having((it) => it.isRateLimited, 'isRateLimited', isTrue) + .having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 30)), + ); + }); + + test('reports a body the transport could not decode as an SDK failure', () { + // Dio raises transport trouble under its own types, so an `unknown` + // without a response came out of its pipeline — its response transformer + // failing on a truncated body above all. The server answered and did + // what was asked; reporting that as a network failure would say the + // outcome is unknown and invite a retry of a write already performed. + final exception = DioException( + requestOptions: RequestOptions(path: '/test'), + error: const FormatException('Unexpected end of input'), + ).toStreamException(); + + expect( + exception, + isA().having((it) => it.cause, 'cause', isA()), + ); + }); + + test('keeps a status the server answered with, even when the body would not decode', () { + // A response means a verdict was reached, whatever its body turned out + // to be. Reading this as an SDK failure would lose the status. + final exception = _failure( + body: 'gateway error', + statusCode: 502, + ).toStreamException(); + + expect(exception, isA().having((it) => it.statusCode, 'statusCode', 502)); + }); + + test('survives a Retry-After sent more than once', () { + // Reading it as a single value throws, and this runs while an error is + // already being reported — so the mapper itself would fail, and the + // failure would escape the seam that promises to classify it. + final exception = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'rate limited'), + statusCode: 429, + headers: const { + 'retry-after': ['30', '60'], + }, + ).toStreamException(); + + expect( + exception, + isA().having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 30)), + ); + }); + + test('reports a bare status when a payload field is not the type it should be', () { + // The mapper runs while a failure is already being reported, so nothing + // in it may throw: a field of the wrong type has to cost the payload, + // not the error. + final exception = _failure( + body: const {'code': 'not-a-number', 'message': 'refused', 'StatusCode': 401}, + statusCode: 401, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.statusCode, 'statusCode', 401) + .having((it) => it.code, 'code', isNull), + ); + }); + + test('reads the details a moderation rejection sends as objects', () { + // The case `_detailsFromJson` exists for: code 73 sends a list of + // objects rather than of numbers, which must read as empty rather than + // failing the whole error. + final exception = _failure( + body: _errorBody( + code: 73, + details: const [ + {'field': 'text'}, + ], + ), + statusCode: 400, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 73) + .having((it) => it.apiError?.details, 'apiError.details', isEmpty), + ); + }); + + test('reports a bare status when the payload will not decode', () { + // A payload missing a field it once required still answers with what a + // caller needs, rather than losing the whole error. + final exception = _failure( + body: {'code': 40, 'message': 'token expired', 'StatusCode': 401}, + statusCode: 401, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.isTokenExpired, 'isTokenExpired', isTrue), + ); + }); + + test('reads the Stream error from a decoded body', () { + final exception = _failure(body: _errorBody(), statusCode: 401).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.statusCode, 'statusCode', 401) + .having((it) => it.message, 'message', 'token expired') + .having((it) => it.isTokenExpired, 'isTokenExpired', isTrue), + ); + }); + + test('reads the Stream error from a body the server sent as text', () { + // Without a JSON content type Dio hands the body over as a string, and the refusal is the + // same one either way. + final exception = _failure(body: jsonEncode(_errorBody()), statusCode: 401).toStreamException(); + + expect(exception, isA().having((it) => it.code, 'code', 40)); + }); + + test('survives a body whose details is an object rather than a list', () { + // The backend serializes `details` as either a list or an object; the object variant must + // not fail the whole error on the way out. + final body = _errorBody(details: {'test': true}); + final exception = _failure(body: body, statusCode: 401).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.apiError?.details, 'apiError.details', isEmpty), + ); + }); + + test('reports a verdict even when the body is not a Stream error', () { + // A proxy or gateway can answer with an error of its own: still a verdict, just one without + // a Stream code. + final dioException = _failure( + body: {'error': 'gateway timeout'}, + statusCode: 504, + statusMessage: 'Gateway Timeout', + ); + + final exception = dioException.toStreamException(); + + expect( + exception, + isA() + .having((it) => it.message, 'message', 'Gateway Timeout') + .having((it) => it.statusCode, 'statusCode', 504) + .having((it) => it.code, 'code', isNull) + .having((it) => it.apiError, 'apiError', isNull) + // Nothing better to blame, so the transport failure is the cause. + .having((it) => it.cause, 'cause', same(dioException)), + ); + }); + + test('reads the Retry-After header on a rate limited response', () { + final rateLimited = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'Too many requests'), + statusCode: 429, + headers: { + 'retry-after': ['7'], + }, + ).toStreamException(); + + expect( + rateLimited, + isA() + .having((it) => it.isRateLimited, 'isRateLimited', isTrue) + .having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 7)), + ); + }); + + test('drops a Retry-After that is not a non-negative number of seconds', () { + StreamException withHeader(String value) => _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'Too many requests'), + statusCode: 429, + headers: { + 'retry-after': [value], + }, + ).toStreamException(); + + expect(withHeader('-7'), isA().having((it) => it.retryAfter, 'retryAfter', isNull)); + expect(withHeader('soon'), isA().having((it) => it.retryAfter, 'retryAfter', isNull)); + }); + + test('reports no verdict when there is no response at all', () { + final exception = _failure(message: 'connection refused').toStreamException(); + + expect( + exception, + isA() + .having((it) => it.message, 'message', 'connection refused') + .having((it) => it.isCancelled, 'isCancelled', isFalse), + ); + }); + + test('marks a timeout as such', () { + final exception = _failure(type: DioExceptionType.receiveTimeout).toStreamException(); + + expect(exception, isA().having((it) => it.isTimeout, 'isTimeout', isTrue)); + }); + + test('marks a request the caller cancelled as such', () { + final cancelled = _failure(type: DioExceptionType.cancel).toStreamException(); + + // A caller that called the request off should not be shown it as a failure. + expect(cancelled, isA().having((it) => it.isCancelled, 'isCancelled', isTrue)); + }); + + test('never leaves the message empty of meaning, so a caller always has something to show', () { + final exception = _failure().toStreamException(); + + expect(exception.message, isNotEmpty); + }); + + test('passes an already mapped exception through untouched', () { + const mapped = StreamAuthenticationException(message: 'no token'); + final dioException = StreamDioException( + exception: mapped, + requestOptions: RequestOptions(path: '/test'), + ); + + expect(dioException.toStreamException(), same(mapped)); + }); + + test('keeps the classification of a Stream exception thrown loose in the chain', () { + // An exception thrown inside an interceptor arrives wrapped in a plain + // DioException; re-diagnosing it from the wrapper would read an + // authentication failure as a network one. + const loose = StreamAuthenticationException(message: 'no token'); + final wrapped = DioException( + requestOptions: RequestOptions(path: '/test'), + error: loose, + ); + + expect(wrapped.toStreamException(), same(loose)); + }); + }); + + group('runApiSafely', () { + test('maps a transport failure onto the exception it represents', () async { + final result = await runApiSafely( + () => throw _failure(body: _errorBody(), statusCode: 401), + ); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.code, 'code', 40), + ); + }); + + test('keeps a Stream exception as it was raised', () async { + const raised = StreamAuthenticationException(message: 'no token'); + final result = await runApiSafely(() => throw raised); + + expect(result.exceptionOrNull(), same(raised)); + }); + + test('reports a response that would not decode as an SDK failure', () async { + // The call closure decodes wire data; a server that renamed a field + // throws a TypeError there, which must surface as a handleable failure. + final result = await runApiSafely(() { + const Object renamed = 'not an int'; + return renamed as int; + }); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }); + + test('reports a bug under the seam as an SDK failure carrying the Error', () async { + final result = await runApiSafely(() => throw StateError('misuse under the seam')); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }); + }); +} diff --git a/packages/stream_core/test/errors/stream_error_code_test.dart b/packages/stream_core/test/errors/stream_error_code_test.dart new file mode 100644 index 00000000..5168547b --- /dev/null +++ b/packages/stream_core/test/errors/stream_error_code_test.dart @@ -0,0 +1,40 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamErrorCode', () { + test('behaves as its number, so an unknown code still reads', () { + // The registry grows server-side. A code with no constant yet has to + // survive the wire rather than fail the error it arrived on. + const unknown = StreamErrorCode(9999); + + expect(unknown, 9999); + expect(unknown.toString(), '9999'); + expect(StreamErrorCode.tokenExpired, 40); + }); + + test('reads an integral double, the shape a JSON number takes on web', () { + expect(StreamErrorCode.fromJson(40), StreamErrorCode.tokenExpired); + expect(StreamErrorCode.fromJson(40.0), StreamErrorCode.tokenExpired); + }); + + test('names the fix rather than the number', () { + expect(StreamErrorCode.tokenExpired.isTokenExpired, isTrue); + expect(StreamErrorCode.tokenSignatureInvalid.isTokenExpired, isFalse); + + // Two codes, one condition: a clock that disagrees with the token's + // claims, which waiting fixes and a fresh token does not. + expect(StreamErrorCode.tokenNotValidYet.isTokenNotYetValid, isTrue); + expect(StreamErrorCode.tokenUsedBeforeIssuedAt.isTokenNotYetValid, isTrue); + expect(StreamErrorCode.tokenExpired.isTokenNotYetValid, isFalse); + + expect(StreamErrorCode.tokenSignatureInvalid.isTokenSignatureInvalid, isTrue); + expect(StreamErrorCode.apiKeyInvalid.isApiKeyInvalid, isTrue); + }); + + test('round-trips through json', () { + expect(StreamErrorCode.toJson(StreamErrorCode.rateLimited), 9); + expect(StreamErrorCode.fromJson(StreamErrorCode.toJson(StreamErrorCode.inputError)), StreamErrorCode.inputError); + }); + }); +} diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart new file mode 100644 index 00000000..d3c1e913 --- /dev/null +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -0,0 +1,230 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +StreamApiError _apiError({ + int code = 40, + int statusCode = 401, + String message = 'token expired', + String moreInfo = '', + bool? unrecoverable, +}) => StreamApiError( + code: StreamErrorCode(code), + details: const [], + duration: '0ms', + message: message, + moreInfo: moreInfo, + statusCode: statusCode, + unrecoverable: unrecoverable, +); + +void main() { + group('StreamException', () { + test('is an Exception, so a blanket handler still sees it', () { + // Nothing at compile time enforces the root's `implements Exception`; + // losing it would silently skip Stream failures in `on Exception` code. + expect(const StreamClientException(message: 'broken'), isA()); + }); + + test('compares by what it carries', () { + const cause = FormatException('bad json'); + + expect( + const StreamClientException(message: 'broken', cause: cause), + const StreamClientException(message: 'broken', cause: cause), + ); + expect( + const StreamClientException(message: 'broken'), + isNot(const StreamNetworkException(message: 'broken')), + ); + }); + + test('tryFrom keeps one of ours, lifts a payload, and reads null otherwise', () { + const ours = StreamAuthenticationException(message: 'no token'); + final payload = _apiError(); + + expect(StreamException.tryFrom(ours), same(ours)); + expect( + StreamException.tryFrom(payload), + isA().having((it) => it.apiError, 'apiError', same(payload)), + ); + expect(StreamException.tryFrom(StateError('bug')), isNull); + }); + + test('prints its kind, its message and its cause', () { + const exception = StreamClientException( + message: 'the event would not decode', + cause: FormatException('bad json'), + ); + + final printed = exception.toString(); + + // A log line has to say what happened without anyone unwrapping the object. + expect(printed, contains('StreamClientException')); + expect(printed, contains('the event would not decode')); + expect(printed, contains('bad json')); + }); + }); + + group('StreamApiException', () { + test('is built from the server payload, carrying it whole', () { + final apiError = _apiError(moreInfo: 'https://getstream.io/docs'); + final exception = StreamApiException.fromApiError(apiError); + + expect(exception.message, 'token expired'); + expect(exception.code, 40); + expect(exception.statusCode, 401); + expect(exception.moreInfo, 'https://getstream.io/docs'); + expect(exception.apiError, same(apiError)); + }); + + test('reads an empty moreInfo as absent', () { + // WebSocket errors carry an empty string where REST errors carry a URL. + expect(StreamApiException.fromApiError(_apiError()).moreInfo, isNull); + }); + + test('reads an absent unrecoverable as false, never as "retryable"', () { + expect(StreamApiException.fromApiError(_apiError()).unrecoverable, isFalse); + expect(StreamApiException.fromApiError(_apiError(unrecoverable: true)).unrecoverable, isTrue); + }); + + test('tells the token conditions apart, since each has a different fix', () { + // 40 expired: a fresh token fixes it. 41/42 not valid yet: waiting fixes it. 43 wrong + // secret and 2 wrong API key: configuration, nothing at runtime fixes them. + StreamApiException forCode(int code) => StreamApiException.fromApiError(_apiError(code: code)); + + expect(forCode(40).isTokenExpired, isTrue); + expect(forCode(41).isTokenNotYetValid, isTrue); + expect(forCode(42).isTokenNotYetValid, isTrue); + expect(forCode(43).isTokenSignatureInvalid, isTrue); + expect(forCode(2).isApiKeyInvalid, isTrue); + + // Each condition is exactly one of the four. + for (final code in [40, 41, 42, 43, 2]) { + final it = forCode(code); + final holds = [it.isTokenExpired, it.isTokenNotYetValid, it.isTokenSignatureInvalid, it.isApiKeyInvalid]; + expect(holds.where((held) => held), hasLength(1), reason: 'code $code'); + } + }); + + test('carries a code the SDK does not know as its number', () { + // The registry grows server-side; an unnamed code must survive decoding + // and compare as a plain number. + final exception = StreamApiException.fromApiError(_apiError(code: 999)); + + expect(exception.code, 999); + expect(exception.isTokenExpired, isFalse); + }); + + test('reads a rate limit off the status, not the code', () { + expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 429)).isRateLimited, isTrue); + expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 500)).isRateLimited, isFalse); + }); + + test('carries no code for a verdict that was not a Stream error', () { + // An edge or proxy answers with a status and no Stream payload. No sentinel stands in for + // the missing code, because any number would collide with a real one. + const exception = StreamApiException(message: 'Gateway Timeout', statusCode: 504); + + expect(exception.code, isNull); + expect(exception.apiError, isNull); + expect(exception.toString(), isNot(contains('code:'))); + expect(exception.toString(), contains('(statusCode: 504)')); + }); + + test('prints the facts that change what a caller does next', () { + const exception = StreamApiException( + message: 'Too many requests', + statusCode: 429, + code: StreamErrorCode.rateLimited, + unrecoverable: true, + retryAfter: Duration(seconds: 7), + ); + + final printed = exception.toString(); + + expect(printed, contains('unrecoverable')); + expect(printed, contains('retryAfter: 7s')); + }); + + test('prints the facts a support ticket needs', () { + final exception = StreamApiException.fromApiError( + _apiError(moreInfo: 'https://getstream.io/docs/errors'), + ); + + final printed = exception.toString(); + + expect(printed, contains('code: 40')); + expect(printed, contains('statusCode: 401')); + expect(printed, contains('token expired')); + expect(printed, contains('https://getstream.io/docs/errors')); + }); + + test('a different retained payload is a different failure', () { + // Two refusals equal in every fact can still differ in what the server + // sent — `apiError` is state, so it must count. + StreamApiException withDetails(List details) => StreamApiException.fromApiError( + StreamApiError( + code: StreamErrorCode.inputError, + details: details, + duration: '0ms', + message: 'refused', + moreInfo: '', + statusCode: 400, + ), + ); + + expect(withDetails(const [1]), isNot(withDetails(const [2]))); + expect(withDetails(const [1]), withDetails(const [1])); + }); + + test('a fact the constructor was handed on its own is a different failure', () { + // The payload overlaps `statusCode`, `code` and `moreInfo`, so `props` + // carrying both looks redundant — but the constructor takes them with no + // payload at all, which is how the WebSocket path and every subclass + // build one. Dropping them would make two refusals that behave in + // opposite ways compare equal. + const expired = StreamApiException(message: 'Refused', statusCode: 401, code: StreamErrorCode.tokenExpired); + const badSignature = StreamApiException( + message: 'Refused', + statusCode: 401, + code: StreamErrorCode.tokenSignatureInvalid, + ); + + expect(expired, isNot(badSignature)); + expect(expired.isTokenExpired, isNot(badSignature.isTokenExpired)); + + expect( + const StreamApiException(message: 'Refused', statusCode: 401), + isNot(const StreamApiException(message: 'Refused', statusCode: 500)), + ); + }); + }); + + group('StreamNetworkException', () { + test('prints the close code when the failure was a socket closure', () { + const closed = StreamNetworkException( + message: 'The connection was closed unexpectedly', + closeCode: CloseCode.abnormalClosure, + ); + + expect(closed.toString(), contains('(closeCode: 1006)')); + expect(const StreamNetworkException(message: 'offline').toString(), isNot(contains('closeCode'))); + }); + + test('a different fact is a different failure', () { + // `props` must see every field, or two failures that behave differently compare equal. + expect( + const StreamNetworkException(message: 'gone', isCancelled: true), + isNot(const StreamNetworkException(message: 'gone')), + ); + expect( + const StreamNetworkException(message: 'gone', isTimeout: true), + isNot(const StreamNetworkException(message: 'gone')), + ); + expect( + const StreamNetworkException(message: 'gone', closeCode: CloseCode.normalClosure), + isNot(const StreamNetworkException(message: 'gone')), + ); + }); + }); +} diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart index 19fb2641..99579604 100644 --- a/packages/stream_core/test/helpers/ws_client_tester.dart +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -284,7 +284,7 @@ WsClientTester buildTester({ WebSocketAuthenticator _authenticatorFor(TokenManager tokens) { return (send, previousError) async { // The refusal another token repairs. Left cached, the same token would be offered again. - if (previousError?.isTokenExpiredError ?? false) tokens.expireToken(); + if (previousError?.isTokenExpired ?? false) tokens.expireToken(); final token = await tokens.getToken(); send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 35f011d9..1143557b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -177,7 +177,7 @@ void main() { // A provider refusing one caller refuses all five, so asking it five times over would // hammer a token endpoint that has already said no. for (final future in futures) { - await expectLater(future, throwsStateError); + await expectLater(future, throwsA(isA())); } expect(provider.loadCount, 1); @@ -212,13 +212,107 @@ void main() { }); final manager = TokenManager(userId: 'user-1', tokenProvider: provider); - await expectLater(manager.getToken(), throwsStateError); + // Whatever the provider threw arrives as an authentication failure, + // with the original error preserved as its cause. + await expectLater( + manager.getToken(), + throwsA(isA().having((it) => it.cause, 'cause', isStateError)), + ); expect(manager.peekToken(), isNull); final token = await manager.getToken(); expect(token, generateTestUserToken('user-1')); expect(provider.loadCount, 2); }); + + test('reports the failure with the trace the provider raised it at', () async { + final raised = StackTrace.current; + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) => Future.error(StateError('load failed'), raised), + ), + ); + + // A plain rethrow would restart the trace inside `TokenManager`, which + // points at the SDK rather than at the code that actually failed. + await expectLater( + manager.getToken(), + throwsA(isA()), + ); + + try { + await manager.getToken(); + fail('expected a throw'); + } catch (_, stackTrace) { + expect(stackTrace, same(raised)); + } + }); + + test('keeps a failure the provider already classified', () async { + const failure = StreamNetworkException(message: 'The token endpoint was unreachable'); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => throw failure), + ); + + // A provider saying "this was the moment" must stay a network failure: wrapped as an + // authentication one it would read as "fix the credentials" and stop the reconnect. + await expectLater(manager.getToken(), throwsA(same(failure))); + }); + + test('reads a provider that could not reach its endpoint as a network failure', () async { + // The ordinary provider fetches over Dio. Blaming the credentials for a + // blip would leave the connection down until the app connects again. + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) async => throw DioException.connectionError( + requestOptions: RequestOptions(path: '/token'), + reason: 'network is unreachable', + ), + ), + ); + + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('keeps a refusal the token endpoint answered with', () async { + // A refusal is the server's verdict, not the moment's, so it must not + // read as retriable the way an unreachable endpoint does. + final options = RequestOptions(path: '/token'); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) async => throw DioException.badResponse( + statusCode: 403, + requestOptions: options, + response: Response(requestOptions: options, statusCode: 403), + ), + ), + ); + + await expectLater( + manager.getToken(), + throwsA(isA().having((it) => it.statusCode, 'statusCode', 403)), + ); + }); + + test('reads a provider timeout as a network failure', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => throw TimeoutException('took too long')), + ); + + await expectLater( + manager.getToken(), + throwsA( + isA() + .having((it) => it.isTimeout, 'isTimeout', isTrue) + .having((it) => it.cause, 'cause', isA()), + ), + ); + }); }); group('expireToken', () { @@ -353,7 +447,7 @@ void main() { expect(manager.userId, isNull); expect(manager.peekToken(), isNull); expect(manager.usesStaticProvider, isFalse); - await expectLater(manager.getToken(), throwsA(isA())); + await expectLater(manager.getToken(), throwsA(isA())); }); test('loads once an identity is supplied', () async { @@ -380,7 +474,7 @@ void main() { expect(manager.userId, isNull); expect(manager.peekToken(), isNull); - await expectLater(manager.getToken(), throwsA(isA())); + await expectLater(manager.getToken(), throwsA(isA())); }); test('leaves the manager reusable for another user', () async { @@ -403,7 +497,7 @@ void main() { completer.complete(generateTestUserToken('user-1')); // A reset is a logout, so the token is neither cached nor handed to the caller. - await expectLater(inFlight, throwsA(isA())); + await expectLater(inFlight, throwsA(isA())); expect(manager.peekToken(), isNull); }); }); @@ -448,7 +542,7 @@ void main() { tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), ); - await expectLater(manager.getToken(), throwsArgumentError); + await expectLater(manager.getToken(), throwsA(isA())); expect(manager.peekToken(), isNull); }); }); diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart index 02934abe..fd9fd482 100644 --- a/packages/stream_core/test/utils/result_test.dart +++ b/packages/stream_core/test/utils/result_test.dart @@ -109,4 +109,15 @@ void main() { expect(recovered.exceptionOrNull(), isA()); }); }); + + group('runSafely', () { + test('captures whatever was thrown untouched, Error included', () async { + // The generic capture stores raw truth; classification belongs to the + // boundary above it. + final bug = StateError('a bug under the capture'); + final result = await runSafely(() => throw bug); + + expect(result.exceptionOrNull(), same(bug)); + }); + }); } diff --git a/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart deleted file mode 100644 index f6ee932a..00000000 --- a/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -StreamApiError _apiError({required int code}) => StreamApiError( - code: code, - details: const [], - duration: '0ms', - message: 'error $code', - moreInfo: '', - statusCode: 401, -); - -void main() { - group('WebSocketEngineException', () { - test('reads the API error out of a closure the server explained', () { - final apiError = _apiError(code: 40); - - // This is what decides whether a closure is reconnected, so it has to survive being wrapped. - expect(WebSocketEngineException(error: apiError).apiError, apiError); - }); - - test('has no API error for a closure that carried something else', () { - // A socket that gave out carries the failure it hit, which says nothing about credentials. - expect(WebSocketEngineException(error: StateError('socket died')).apiError, isNull); - }); - - test('stands in for a code and reason it was not given', () { - const exception = WebSocketEngineException(); - - // A closure with no code is not the same as one closed normally, which is never reconnected. - expect(exception.code, 0); - expect(exception.code, isNot(CloseCode.normalClosure)); - expect(exception.reason, 'Unknown'); - }); - - test('stands in for a code given as null', () { - // The engine reads this off a socket, which reports null for a closure it never saw. Unlike - // the reason, the code has a non-null default, so passing null has to be handled too. - expect(const WebSocketEngineException(code: null).code, 0); - }); - - test('compares by what it carries', () { - final apiError = _apiError(code: 40); - - expect( - WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), - WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), - ); - expect( - const WebSocketEngineException(code: 1000), - isNot(const WebSocketEngineException(code: 1011)), - ); - }); - }); -} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 100893f9..231df881 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -40,7 +40,7 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error?.error, 'error', isNotNull), + isA().having((it) => it.error, 'error', isA()), ), ); expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); @@ -187,6 +187,37 @@ void main() { ); }); + group('send', () { + wsClientTest( + 'throws when nothing has connected yet, which is an order to fix rather than a failure', + connect: (_) {}, // never connected + body: (tester) { + // Reported where the caller wrote it: no retry, and no reconnection, + // makes a send that came before the connection work. + expect( + () => tester.client.send(const HealthCheckPingEvent(connectionId: 'connection-id')), + throwsStateError, + ); + }, + ); + + wsClientTest( + 'fails as a network problem when a connection that was established has dropped', + body: (tester) async { + await tester.client.disconnect(); + + // A drop can race any send, so a correct caller can hit this: it + // reads as the moment's failure, classified like every other one. + final result = tester.client.send(const HealthCheckPingEvent(connectionId: 'connection-id')); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }, + ); + }); + group('authenticate', () { wsClientTest( 'presents credentials once the socket is open, while authenticating', @@ -256,8 +287,8 @@ void main() { group('the refusal handed to the next attempt', () { /// Records what each attempt was told about the previous one. - ({WebSocketAuthenticator authenticator, List seen}) watching() { - final seen = []; + ({WebSocketAuthenticator authenticator, List seen}) watching() { + final seen = []; return ( authenticator: (send, previousError) async { seen.add(previousError); @@ -279,7 +310,7 @@ void main() { await tester.pumpEventQueue(); // The second attempt is told why the first ended, so it can present something else. - expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); + expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); }); test('is handed on even when the closure is not one to reconnect from', () async { @@ -298,7 +329,7 @@ void main() { // A caller who connects again is presenting credentials of their own, and needs to be told // what the last ones were refused for however the closure was classified. - expect(seen, [null, isA().having((it) => it.code, 'code', 43)]); + expect(seen, [null, isA().having((it) => it.code, 'code', 43)]); }); test('is absent once a connection has been established', () async { @@ -318,11 +349,11 @@ void main() { await tester.client.connect(); await tester.pumpEventQueue(); - expect(seen, [null, isA(), null]); + expect(seen, [null, isA(), null]); }); test('is absent after a closure the server did not cause', () async { - final seen = []; + final seen = []; final tester = buildTester( // Declines, the way an authenticator with nothing left to offer does, which closes the // connection as `AuthenticationFailed`. @@ -349,7 +380,7 @@ void main() { await tester.client.connect(); await tester.pumpEventQueue(); - expect(seen, [null, isA(), null]); + expect(seen, [null, isA(), null]); }); }); @@ -367,7 +398,13 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error, 'error', isStateError), + isA().having( + (it) => it.error, + 'error', + // Whatever the authenticator threw arrives as an authentication + // failure, with the original error preserved as its cause. + isA().having((it) => it.cause, 'cause', isStateError), + ), ), ); }, @@ -390,6 +427,32 @@ void main() { }, ); + wsClientTest( + 'retries when the token provider named the moment rather than the credentials', + authenticator: (_, _) async => throw const StreamNetworkException( + message: 'The token endpoint was unreachable', + ), + recover: true, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // A failure the provider classified itself keeps its kind all the way here, which is what + // makes the reconnect carve-out reachable at all. + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isA()), + ), + ); + + expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); + }, + ); + wsClientTest( 'reports a refusal the server sent, carrying what it said', connect: (tester) async { @@ -410,9 +473,9 @@ void main() { (it) => it.source, 'source', isA().having( - (it) => it.error?.apiError?.code, - 'apiError.code', - 43, + (it) => it.error, + 'error', + isA().having((it) => it.code, 'code', 43), ), ), ); @@ -785,7 +848,11 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error?.error, 'error.error', isStateError), + isA().having( + (it) => it.error, + 'error', + isA().having((it) => it.cause, 'cause', isStateError), + ), ), ); }, @@ -1087,10 +1154,12 @@ void main() { // rather than presenting the same token again. WebSocketAuthenticator authenticatorFor(TokenManager tokens) { return (send, previousError) async { - if (previousError?.isTokenExpiredError ?? false) { + if (previousError?.isTokenExpired ?? false) { tokens.expireToken(); if (tokens.usesStaticProvider) { - throw ClientException(message: 'The token was refused and the provider has no other to give'); + throw const StreamAuthenticationException( + message: 'The token was refused and the provider has no other to give', + ); } } @@ -1101,7 +1170,7 @@ void main() { test('is still answered for by the attempt after it, when the user has not changed', () { fakeAsync((async) { - final asked = []; + final asked = []; final tokens = TokenManager( userId: 'user-1', tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), @@ -1128,7 +1197,7 @@ void main() { // Nothing replaced the credentials, so the refusal still describes what this attempt holds // and forgetting it would leave the same token offered again. - expect(asked, [null, isA().having((it) => it.code, 'code', 40)]); + expect(asked, [null, isA().having((it) => it.code, 'code', 40)]); }); }); diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart index 39dfa8bd..1e7ba16b 100644 --- a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -18,7 +18,7 @@ StreamApiError _apiError({ required int code, int statusCode = 401, }) => StreamApiError( - code: code, + code: StreamErrorCode(code), details: const [], duration: '0ms', message: 'error $code', @@ -26,20 +26,20 @@ StreamApiError _apiError({ statusCode: statusCode, ); -final _expiredToken = _apiError(code: 40); +final _expiredToken = StreamApiException.fromApiError(_apiError(code: 40)); -Disconnected _serverClosure(StreamApiError? apiError) => Disconnected( - source: ServerInitiated(error: WebSocketEngineException(error: apiError)), +Disconnected _serverClosure(StreamApiException? error) => Disconnected( + source: ServerInitiated(error: error), ); /// Builds a handler, along with the errors it handed the authenticator and the failures it reported. ({ WebSocketAuthenticationHandler authentication, - List asked, + List asked, List failures, }) _subject({WebSocketAuthenticator? authenticator}) { - final asked = []; + final asked = []; final failures = []; final authentication = WebSocketAuthenticationHandler( @@ -50,7 +50,7 @@ _subject({WebSocketAuthenticator? authenticator}) { send(const _PingRequest()).getOrThrow(); }, send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); return (authentication: authentication, asked: asked, failures: failures); @@ -102,7 +102,7 @@ void main() { final running = authentication.authenticate(); // The server refuses again while this attempt is still awaiting its credentials. - authentication.onConnectionStateChanged(_serverClosure(_apiError(code: 40))); + authentication.onConnectionStateChanged(_serverClosure(StreamApiException.fromApiError(_apiError(code: 40)))); held.complete(); await running; @@ -163,7 +163,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: null, send: (_) => const Result.success(null), - onFailure: (_) => fail('nothing to authenticate, so nothing can fail'), + onFailure: (_, _) => fail('nothing to authenticate, so nothing can fail'), ); await expectLater(authentication.authenticate(), completes); @@ -186,7 +186,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: (_, _) => loaded.future, send: (_) => const Result.success(null), - onFailure: (_) => fail('the credentials went out'), + onFailure: (_, _) => fail('the credentials went out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -217,7 +217,7 @@ void main() { sent.add(request); return const Result.success(null); }, - onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + onFailure: (_, _) => fail('the credentials were never offered, so nothing failed to go out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -245,7 +245,7 @@ void main() { throw StateError('token load failed'); }, send: (_) => const Result.success(null), - onFailure: (_) => fail('the attempt this failure belongs to had already been closed'), + onFailure: (_, _) => fail('the attempt this failure belongs to had already been closed'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -275,7 +275,7 @@ void main() { sent.add(request); return const Result.success(null); }, - onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + onFailure: (_, _) => fail('the credentials were never offered, so nothing failed to go out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -304,7 +304,7 @@ void main() { throw StateError('token load failed'); }, send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); authentication.onConnectionStateChanged(const Connecting()); @@ -325,7 +325,7 @@ void main() { test('leaves the refusal for the attempt that replaces it', () async { final loaded = Completer(); - final asked = []; + final asked = []; var calls = 0; final authentication = WebSocketAuthenticationHandler( @@ -335,7 +335,7 @@ void main() { await loaded.future; }, send: (_) => const Result.success(null), - onFailure: (_) {}, + onFailure: (_, _) {}, ); authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); @@ -362,7 +362,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: (_, _) async => throw StateError('token load failed'), send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); authentication.onConnectionStateChanged(const Connecting()); diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 04554c3f..59f54026 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -2,7 +2,7 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( - code: code, + code: StreamErrorCode(code), details: const [], duration: '0ms', message: 'error $code', @@ -11,9 +11,7 @@ StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( ); Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( - source: ServerInitiated( - error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), - ), + source: ServerInitiated(error: StreamApiException.fromApiError(apiError)), ); const _healthCheck = HealthCheckInfo(connectionId: 'connection-id'); @@ -32,17 +30,24 @@ Iterable _everyStateBut(WebSocketConnectionState excep void main() { test('automatic reconnection is enabled when the token has expired, since the next attempt loads another', () { - // 40 is an expired token, the one token error that fixes itself, because a fresh token is + // 40 is an expired token, the token error that fixes itself, because a fresh token is // loaded before the next attempt authenticates. final state = _serverDisconnect(_apiError(40)); expect(state.isAutomaticReconnectionEnabled, isTrue); }); - test('automatic reconnection is disabled when another token would be refused too', () { - // 41 not valid yet, 42 used before issued, 43 wrong secret, 2 wrong API key. A fresh token - // repairs none of them. - for (final code in [41, 42, 43, 2]) { + test('automatic reconnection is enabled when the token is not valid yet, since waiting is the fix', () { + // 41 not valid yet and 42 used before issued are clock-skew conditions: the token becomes + // valid on its own, so a later attempt can get past them where a fresh token cannot. + for (final code in [41, 42]) { + expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isTrue, reason: 'code $code'); + } + }); + + test('automatic reconnection is disabled when another attempt would be refused too', () { + // 43 wrong secret, 2 wrong API key: configuration problems every retry reproduces. + for (final code in [43, 2]) { expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isFalse, reason: 'code $code'); } }); @@ -61,6 +66,25 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is disabled when the server said retrying will not help', () { + const unrecoverable = StreamApiError( + code: StreamErrorCode.notAllowed, + details: [], + duration: '0ms', + message: 'not allowed', + moreInfo: '', + statusCode: 500, + unrecoverable: true, + ); + + // A 500 would otherwise reconnect; the server's own verdict overrides the status. + final state = Disconnected( + source: ServerInitiated(error: StreamApiException.fromApiError(unrecoverable)), + ); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + test('automatic reconnection is enabled for a server-side failure', () { // Stream error codes never fall in 400..499, so this is classified by the status code alone. final state = _serverDisconnect(_apiError(9, statusCode: 500)); @@ -70,12 +94,24 @@ void main() { test('automatic reconnection is disabled when the socket was closed deliberately', () { const state = Disconnected( - source: ServerInitiated(error: WebSocketEngineException(code: CloseCode.normalClosure)), + source: ServerInitiated( + error: StreamNetworkException(message: 'bye', closeCode: CloseCode.normalClosure), + ), ); expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is enabled when the socket was lost without a verdict', () { + const state = Disconnected( + source: ServerInitiated( + error: StreamNetworkException(message: 'gone', closeCode: CloseCode.abnormalClosure), + ), + ); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + test('automatic reconnection is enabled when the server closed without saying why', () { const state = Disconnected(source: ServerInitiated()); @@ -84,9 +120,29 @@ void main() { test('automatic reconnection is disabled when a connection could not be authenticated, since the ' 'same credentials would fail again', () { - const state = Disconnected(source: AuthenticationFailed(error: 'no token')); + const state = Disconnected( + source: AuthenticationFailed(error: StreamAuthenticationException(message: 'no token')), + ); + // A failure that named nothing is no better a case for trying again. + const bare = Disconnected(source: AuthenticationFailed()); expect(state.isAutomaticReconnectionEnabled, isFalse); + expect(bare.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled when authentication failed on the network, since the ' + 'moment is at fault rather than the credentials', () { + // A token endpoint that was briefly unreachable classifies as a network + // failure and passes through the token manager as itself. + const transient = Disconnected( + source: AuthenticationFailed(error: StreamNetworkException(message: 'endpoint unreachable')), + ); + const cancelled = Disconnected( + source: AuthenticationFailed(error: StreamNetworkException(message: 'stopped', isCancelled: true)), + ); + + expect(transient.isAutomaticReconnectionEnabled, isTrue); + expect(cancelled.isAutomaticReconnectionEnabled, isFalse); }); test('automatic reconnection is enabled when a connected socket stops answering health checks', () { @@ -95,6 +151,19 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isTrue); }); + test('a source carries the trace of a failure that was raised, and none for one that was read', () { + final raised = StackTrace.current; + + // Removed from the exception, so the source is what carries it — the same + // shape `Failure` uses, error and trace side by side. + const failure = StreamNetworkException(message: 'gone'); + expect(ServerInitiated(error: failure, stackTrace: raised).stackTrace, same(raised)); + expect(AuthenticationFailed(error: failure, stackTrace: raised).stackTrace, same(raised)); + + // A closure the server reported arrives as data, so there is none. + expect(const ServerInitiated(error: StreamApiException(message: 'refused', statusCode: 403)).stackTrace, isNull); + }); + test('closeReason reads differently for every source', () { const sources = [ UserInitiated(), @@ -102,7 +171,7 @@ void main() { SystemInitiated(), UnHealthyConnection(), ConnectTimeout(), - AuthenticationFailed(error: 'no token'), + AuthenticationFailed(error: StreamAuthenticationException(message: 'no token')), ]; final reasons = sources.map((it) => it.closeReason).toSet(); @@ -129,22 +198,12 @@ void main() { }); group('cause', () { - test('is what the socket failed with, not the exception carrying it', () { - final apiError = _apiError(40); - final source = ServerInitiated( - error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), - ); - - // Reported unwrapped so a caller can match on the error itself. Handed the exception, a - // caller checking for a `StreamApiError` would find none and report the closure as unexplained. - expect(source.cause, same(apiError)); - }); - - test('is the exception itself when it carries nothing but a close code', () { - const exception = WebSocketEngineException(reason: 'gone', code: 4001); - const source = ServerInitiated(error: exception); + test('is the exception the server closed the connection with', () { + final exception = StreamApiException.fromApiError(_apiError(40)); + final source = ServerInitiated(error: exception); - // The close code is the only account of the closure there is, so it stands in. + // The exception is the caller-facing account of the closure; a caller matches on the + // exception kind, and reads the raw payload off `apiError` when they need it. expect(source.cause, same(exception)); }); @@ -153,8 +212,8 @@ void main() { }); test('is what authentication failed with', () { - final error = Exception('the token was refused'); - final source = AuthenticationFailed(error: error); + const error = StreamAuthenticationException(message: 'the token was refused'); + const source = AuthenticationFailed(error: error); expect(source.cause, same(error)); });