Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-ws-swallowed-send-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@asyncapi/generator": minor
"@asyncapi/generator-components": minor
---

Generated Python and JavaScript WebSocket clients no longer swallow send errors. Failures are now forwarded to the registered error handlers and raised by default, so callers learn when a message fails to send. Pass `raise_send_errors=False` (Python) or `throwSendErrors=false` (JavaScript) to the constructor to keep a high-throughput producer loop running and rely on the error handlers instead.
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ Required tags: `@param`, `@returns`, and `@throws` / `@async` where applicable.
### 2.5 Release hygiene
Changesets, release-triggering prefixes, and the full release flow are documented in the [Release process section in `Development.md`](Development.md#release-process). Use that as the source of truth on review; flag PRs whose diffs suggest a release but ship no `.changeset/*.md`.

A changeset must name the **published** package a change ships through — not the directory you edited. `packages/templates/*` are `private` and unpublished, so they are **never** valid changeset targets; baked-in template changes reach users via `@asyncapi/generator`. Map changed files to the changeset package as:

| Changed files | Changeset package(s) |
|---|---|
| `packages/templates/**` (private, baked-in) | `@asyncapi/generator` |
| `apps/generator/**` | `@asyncapi/generator` |
| `packages/components/**` | `@asyncapi/generator-components` |
| `packages/helpers/**` | `@asyncapi/generator-helpers` |
| `apps/keeper/**` | `@asyncapi/keeper` |
| `apps/react-sdk/**` | `@asyncapi/generator-react-sdk` |

A change spanning a shared package and a baked-in template (e.g. `packages/components` + `packages/templates`) needs **one** changeset naming both released packages (`@asyncapi/generator-components` + `@asyncapi/generator`).

---

## 3. Cross-cutting architectural principles
Expand Down
91 changes: 64 additions & 27 deletions packages/components/src/components/SendOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,40 @@ const websocketSendOperationConfig = {
"""
Send a ${methodName} message using the WebSocket connection attached to this instance.

A send failure is always forwarded to every callback registered with
register_error_handler(). What happens next depends on the
raise_send_errors flag passed to the constructor (default: True):

- raise_send_errors=True -- the exception is re-raised after the
handlers run, so the caller can react to each failure (retry,
dead-letter, abort the loop, ...). This is the safe default: failures
are never lost silently.
- raise_send_errors=False -- the exception is suppressed after the
handlers run, so a high-throughput producer loop keeps going and relies
on the registered error handlers for observability.

Args:
message (dict or str): The message to send. Will be serialized to JSON if it's a dictionary.

Raises:
Exception: If sending fails or the socket is not connected.
Exception: If sending fails and raise_send_errors is True.
"""
self._send(message, self.ws_app)`,
try:
self._send(message, self.ws_app)
except Exception as e:
self.handle_error(e)
if self.raise_send_errors:
raise`,
staticMethod: `@staticmethod
def ${staticMethodName}(message, socket):
"""
Send a ${methodName} message using a provided WebSocket connection, without needing an instance.

Being a static method it has no access to the instance error handlers or to
the raise_send_errors flag, so a send failure is always raised to the
caller. Use the instance method ${methodName}() if you want failures routed
through the registered error handlers.

Args:
message (dict or str): The message to send.
socket (websockets.WebSocketCommonProtocol): The WebSocket to send through.
Expand All @@ -61,56 +83,71 @@ def ${staticMethodName}(message, socket):
nonStaticMethod: `/**
* Instance method version of ${methodName} that uses the client's own WebSocket connection.
* Automatically compiles schemas if not already compiled.
*
*
* On a send failure the error is forwarded to every callback registered with
* registerErrorHandler() (or logged if none is registered), then re-thrown unless the
* client was constructed with throwSendErrors=false. Setting throwSendErrors=false keeps a
* high-throughput producer loop running and relies on the registered error handlers instead.
*
* @param {Object} message - The message payload to send
* @returns {Object|undefined} The static method result (e.g. { isValid: true }) on success, or undefined when a failure was suppressed via throwSendErrors=false
* @throws {Error} If WebSocket connection is not established
* @throws {Error} If schema compilation fails
* @throws {Error} If message validation fails against all schemas
* @throws {Error} If sending or validation fails and throwSendErrors is true (the constructor default)
*/
async ${methodName}(message){
if(!this.websocket){
throw new Error('WebSocket connection not established. Call connect() first.');
}
await this.compileOperationSchemas();
const schemas = this.compiledSchemas['${methodName}'];
${clientName}.${methodName}(message, this.websocket, schemas);
try {
return ${clientName}.${methodName}(message, this.websocket, schemas);
} catch (error) {
if (this.errorHandlers.length > 0) {
this.errorHandlers.forEach(handler => handler(error));
} else {
console.error('Error sending ${methodName} message:', error);
}
if (this.throwSendErrors) {
throw error;
}
}
}`,
staticMethod: `/**
* Sends a ${methodName} message over the WebSocket connection.
*
*
* This static method has no access to the instance error handlers or the throwSendErrors
* flag, so any failure (serialization, validation against all schemas, or a closed socket)
* is always thrown to the caller. Use the instance method ${methodName}() if you want
* failures routed through the registered error handlers.
*
* @param {Object} message - The message payload to send. Should match the schema defined in the AsyncAPI document.
* @param {WebSocket} socket - The WebSocket connection to use.
* @param {Array<function>} schemas - Array of compiled schema validator functions for this operation.
* @returns {Object} { isValid: true } once the message has been sent.
* @throws {TypeError} If message cannot be stringified to JSON
* @throws {Error} If WebSocket connection is not in OPEN state
* @throws {Error} If message validation fails against all schemas
*/
static ${methodName}(message, socket, schemas) {
try {
if (!schemas || schemas.length === 0) {
if (!schemas || schemas.length === 0) {
socket.send(JSON.stringify(message));
return { isValid: true };
}
const allValidationErrors = [];
for (const compiledSchema of schemas) {
const validationResult = validateMessage(compiledSchema, message);
if (validationResult.isValid) {
socket.send(JSON.stringify(message));
return { isValid: true };
return { isValid: true };
}
const allValidationErrors = [];
let isValid = false;
for(const compiledSchema of schemas){
const validationResult = validateMessage(compiledSchema, message);
if (validationResult.isValid) {
isValid = true;
socket.send(JSON.stringify(message));
break;
} else {
if (validationResult.validationErrors) {
allValidationErrors.push(...validationResult.validationErrors);
}
}
if (!isValid) {
console.error('Validation errors:', JSON.stringify(allValidationErrors, null, 2));
}
if (validationResult.validationErrors) {
allValidationErrors.push(...validationResult.validationErrors);
}
} catch (error) {
console.error('Error sending ${methodName} message:', error);
}
// No schema matched: surface the failure instead of silently dropping the message.
throw new Error('Message validation failed for ${methodName}: ' + JSON.stringify(allValidationErrors));
}`
};
}
Expand Down
18 changes: 16 additions & 2 deletions packages/components/src/components/readme/Usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,30 @@ const usageConfig = {
python: (clientName, clientFileName) => `
from ${clientFileName.replace('.py', '')} import ${clientName}

# raise_send_errors defaults to True: a failed send raises after the registered
# error handlers run, so you can react to each failure. Pass
# raise_send_errors=False to keep a high-throughput producer loop running and
# rely on registered error handlers instead.
ws_client = ${clientName}()

async def main():
await ws_client.connect()
# use ws_client to send/receive messages
try:
# use ws_client to send/receive messages
pass
except Exception as error:
# only reached when raise_send_errors is True (the default)
print("Send failed:", error)
await ws_client.close()
`,

javascript: (clientName, clientFileName) => `
const ${clientName} = require('./${clientFileName.replace('.js', '')}');

// throwSendErrors defaults to true: a failed send re-throws after the registered
// error handlers run, so you can react to each failure. Pass
// new ${clientName}(undefined, false) to keep a high-throughput producer loop
// running and rely on registered error handlers instead.
const wsClient = new ${clientName}();

async function main() {
Expand All @@ -27,7 +41,7 @@ async function main() {
// use wsClient to send/receive messages
await wsClient.close();
} catch (error) {
console.error('Failed to connect:', error);
console.error('Failed to connect or send:', error);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ npm install

\`\`\`javascript
const AccountServiceClient = require('./myClient');

// throwSendErrors defaults to true: a failed send re-throws after the registered
// error handlers run, so you can react to each failure. Pass
// new AccountServiceClient(undefined, false) to keep a high-throughput producer loop
// running and rely on registered error handlers instead.
const wsClient = new AccountServiceClient();

async function main() {
Expand All @@ -32,7 +37,7 @@ async function main() {
// use wsClient to send/receive messages
await wsClient.close();
} catch (error) {
console.error('Failed to connect:', error);
console.error('Failed to connect or send:', error);
}
}

Expand Down Expand Up @@ -85,11 +90,20 @@ pip install -r requirements.txt
\`\`\`python
from myClient import AccountServiceClient

# raise_send_errors defaults to True: a failed send raises after the registered
# error handlers run, so you can react to each failure. Pass
# raise_send_errors=False to keep a high-throughput producer loop running and
# rely on registered error handlers instead.
ws_client = AccountServiceClient()

async def main():
await ws_client.connect()
# use ws_client to send/receive messages
try:
# use ws_client to send/receive messages
pass
except Exception as error:
# only reached when raise_send_errors is True (the default)
print(\\"Send failed:\\", error)
await ws_client.close()
\`\`\`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,73 @@ exports[`Testing of SendOperation function render js websockets with null send o
exports[`Testing of SendOperation function render js websockets with send operations and client name 1`] = `
"/**
* Sends a sendUserSignedup message over the WebSocket connection.
*
*
* This static method has no access to the instance error handlers or the throwSendErrors
* flag, so any failure (serialization, validation against all schemas, or a closed socket)
* is always thrown to the caller. Use the instance method sendUserSignedup() if you want
* failures routed through the registered error handlers.
*
* @param {Object} message - The message payload to send. Should match the schema defined in the AsyncAPI document.
* @param {WebSocket} socket - The WebSocket connection to use.
* @param {Array<function>} schemas - Array of compiled schema validator functions for this operation.
* @returns {Object} { isValid: true } once the message has been sent.
* @throws {TypeError} If message cannot be stringified to JSON
* @throws {Error} If WebSocket connection is not in OPEN state
* @throws {Error} If message validation fails against all schemas
*/
static sendUserSignedup(message, socket, schemas) {
try {
if (!schemas || schemas.length === 0) {
if (!schemas || schemas.length === 0) {
socket.send(JSON.stringify(message));
return { isValid: true };
}
const allValidationErrors = [];
for (const compiledSchema of schemas) {
const validationResult = validateMessage(compiledSchema, message);
if (validationResult.isValid) {
socket.send(JSON.stringify(message));
return { isValid: true };
return { isValid: true };
}
const allValidationErrors = [];
let isValid = false;
for(const compiledSchema of schemas){
const validationResult = validateMessage(compiledSchema, message);
if (validationResult.isValid) {
isValid = true;
socket.send(JSON.stringify(message));
break;
} else {
if (validationResult.validationErrors) {
allValidationErrors.push(...validationResult.validationErrors);
}
}
if (!isValid) {
console.error('Validation errors:', JSON.stringify(allValidationErrors, null, 2));
}
if (validationResult.validationErrors) {
allValidationErrors.push(...validationResult.validationErrors);
}
} catch (error) {
console.error('Error sending sendUserSignedup message:', error);
}
// No schema matched: surface the failure instead of silently dropping the message.
throw new Error('Message validation failed for sendUserSignedup: ' + JSON.stringify(allValidationErrors));
}

/**
* Instance method version of sendUserSignedup that uses the client's own WebSocket connection.
* Automatically compiles schemas if not already compiled.
*
*
* On a send failure the error is forwarded to every callback registered with
* registerErrorHandler() (or logged if none is registered), then re-thrown unless the
* client was constructed with throwSendErrors=false. Setting throwSendErrors=false keeps a
* high-throughput producer loop running and relies on the registered error handlers instead.
*
* @param {Object} message - The message payload to send
* @returns {Object|undefined} The static method result (e.g. { isValid: true }) on success, or undefined when a failure was suppressed via throwSendErrors=false
* @throws {Error} If WebSocket connection is not established
* @throws {Error} If schema compilation fails
* @throws {Error} If message validation fails against all schemas
* @throws {Error} If sending or validation fails and throwSendErrors is true (the constructor default)
*/
async sendUserSignedup(message){
if(!this.websocket){
throw new Error('WebSocket connection not established. Call connect() first.');
}
await this.compileOperationSchemas();
const schemas = this.compiledSchemas['sendUserSignedup'];
AccountServiceAPI.sendUserSignedup(message, this.websocket, schemas);
try {
return AccountServiceAPI.sendUserSignedup(message, this.websocket, schemas);
} catch (error) {
if (this.errorHandlers.length > 0) {
this.errorHandlers.forEach(handler => handler(error));
} else {
console.error('Error sending sendUserSignedup message:', error);
}
if (this.throwSendErrors) {
throw error;
}
}
}"
`;

Expand All @@ -68,6 +83,11 @@ exports[`Testing of SendOperation function render websockets without send operat
\\"\\"\\"
Send a send_user_signedup message using a provided WebSocket connection, without needing an instance.

Being a static method it has no access to the instance error handlers or to
the raise_send_errors flag, so a send failure is always raised to the
caller. Use the instance method send_user_signedup() if you want failures routed
through the registered error handlers.

Args:
message (dict or str): The message to send.
socket (websockets.WebSocketCommonProtocol): The WebSocket to send through.
Expand All @@ -81,11 +101,28 @@ exports[`Testing of SendOperation function render websockets without send operat
\\"\\"\\"
Send a send_user_signedup message using the WebSocket connection attached to this instance.

A send failure is always forwarded to every callback registered with
register_error_handler(). What happens next depends on the
raise_send_errors flag passed to the constructor (default: True):

- raise_send_errors=True -- the exception is re-raised after the
handlers run, so the caller can react to each failure (retry,
dead-letter, abort the loop, ...). This is the safe default: failures
are never lost silently.
- raise_send_errors=False -- the exception is suppressed after the
handlers run, so a high-throughput producer loop keeps going and relies
on the registered error handlers for observability.

Args:
message (dict or str): The message to send. Will be serialized to JSON if it's a dictionary.

Raises:
Exception: If sending fails or the socket is not connected.
Exception: If sending fails and raise_send_errors is True.
\\"\\"\\"
self._send(message, self.ws_app)"
try:
self._send(message, self.ws_app)
except Exception as e:
self.handle_error(e)
if self.raise_send_errors:
raise"
`;
Loading
Loading