TCP Server: How to return Modbus exception codes?
uklotzde opened this issue · 4 comments
Hello, I'm trying to use tokio-modbus
to create a Modbus Server implementation, and I've also hit this wall.
I took a look at the code and the issues/PR this morning.
I think #226 is a good start and I'll elaborate on that further.
But I think that the downcast is a short fix, and will only postpone the problem to more complex cases. And I also don't like to show more private types to the public API.
The public API should stay simple with Request
, Response
and Error
and nothing else.
1. Modify Response
(breaking change)
The first idea is to add a variant to Response
:
pub enum Reponse {
// [other variants]...
Exception(FunctionCode, Exception),
}
Technically, an exception is still a "valid" response from the Modbus Server.
Pros
- Removes the burden of having a
Result<Response, ExceptionResponse>
inResponsePDU
. - Relax the trait requirements for
Response
in theService
trait.
Cons
- Need to refacto a lot of Client code to handle the new
Response
and return a correctError
.
Conclusion
I think that while this solution is simple, it could lead to misinterpretation. This Response
variant should be well documented and clearly state that its an Error response.
For me, this solution highlights another issue.
The fact that all Error
s in this crate API are std::io::Error
.
2. Changing std::io::Error
to an internal Error
type with more granularity (breaking change)
This is a big change, but imo, it should ease the use, simplify the API and provide better error handling without modifying the Response
part.
The Error
type should allow the user to know what kind of error happened:
- modbus exception
io::Error
- more if needed...
The Error
type should also define if an error is recoverable (i.e: modbus exception), or not (i.e: server disconnected or others I/O errors).
Pros
- Allow the user to match on the specific error types
- Reduce misuse of the library error type
Cons
- This is a breaking change, we need to update the
std::io::Error
into something likecrate::error::Error
. - Need to review some parts in Client and Server to update the logic accordingly.
Example
I'm taking the idea of @emcell here.
Because I think that's the way to go, I've only made a few small changes.
pub enum Error {
/// This is a error returned by the Modbus Server and relative to Modbus.
Exception(ModbusException),
/// This is an error in the Modbus Client side (can or can't be related to Modbus).
Io(std::io::Error),
}
If we take read_input_registers<'a>
, I think it'll look to something like:
let result = modbus.read_input_registers(0x1000, 6).await;
match result {
Ok(rsp) => todo!(),
Err(Error::Exception(exception) => log::error!(format!("failed to read input reg: {}", exception)), // drop error and continue execution
Err(e) => return Err(e), // the error is more important, let the caller handle the failure
}
Conclusion
I think this solution is better suited in order to fix the issue and provide a better API.
(I've started a POC to test if it's possible to update the Error
type without too much changes: #231 )
Feedback welcome
What do you think ? @uklotzde
I think we can find a good way to solve this and make a better API for version 0.10. (if you are open for breaking changes between 0.x).
I had another ideas that popped in my mind will trying to implement the server. I'm putting everything down to bring more possibilities.
3. New Response
type with Valid
and Exception
vairant (breaking change)
The goal is to replace the Response
type in order to be able to identify the type of the reponse (ie. valid or exception).
Example
pub enum Response {
/// The Reponse from the modbus server is a success.
// NOTE: the name is open to discussion.
Valid(ModbusReponse),
/// The Response from the modbus server is an exception.
Exception(ModbusExceptionResponse),
}
ModbusReponse
is the actual Response
type.
ModbusExceptionResponse
is the actual ExceptionResponse
.
I feel this is a natural way of handling things in the server side:
fn call(req: Self::Request) -> Self::Future {
match req {
ReadCoils(addr, quantity) => future::ready(self.read_coils(addr, quantity).map(ModbusResponse::ReadCoils).map_err(|e| ExceptionResponse::new(req.function_code(), e)).into()),
others => future::ready(Ok(Response::Exception(ExceptionResponse::new(others.function_code(), Exception::IllegalFunction))),
}
}
fn read_coils(addr: Address, quantity: u16) -> Result<Vec<u16>, Exception> {
if /* addr not in range */ {
return Err(Exception::IllegalDataAddr);
}
// read coils
Ok(coils)
}
Pros
- We can keep the structure of
Result<Response, std::io::Error>
- The user always needs to check if a
Request
has been properly handled when getting aResponse
Cons
- We need to change a lot of the code in order to update the
Response
type - A
Result<Response, Exception>
can also work in the same way.
Conclusion
I think it's more clear to do this, and force the user to always handle the Response
type in a more elegant way.
4. Add new tokio_modbus::Result
new type (breaking change)
The goal is to have a specialization of a new Result
type in the tokio-modbus
crate like std::io
.
Example
pub enum Result {
/// The result of the operation from the modbus server is a success.
Response(ModbusReponse),
/// The result of the operation from the modbus server is an exception.
Exception(ModbusExceptionResponse),
/// The result of the operation contains fatal failures.
Error(std::io::Error),
}
ModbusReponse
is the actual Response
type.
ModbusExceptionResponse
is the actual ExceptionResponse
.
Pros
- One type to rule them all
- We might change the
Error
variant field with a generic
Cons
- This will require a lot of change in the lib
- Might be unclear to user because a
Result
is typically a 2 variant enum (Ok, Err)
Conclusion
This can be achieved with other solutions with a Result<Response, Error>
, I don't know if it's a good path.
Separating orthogonal classes of errors from different layers in the (network) stack is usually done by nesting results:
Result<Result<Response, ExceptionResponse>, Error>
The outer Result
contains the technical error.
I agree that nesting the error feels more natural.
That will requires changes in different traits
in order to be doable.
Client
pub trait Client: SlaveContext + Send + Debug {
async fn call(&mut self, request: Request<'_>) -> Result<Result<Response, ExceptionResponse>, Error>;
}
Reader/Writer
pub trait Reader: Client {
async fn read_coils(&mut self, _: Address, _: Quantity) -> Result<Result<Vec<Coil>, Exception>, Error>;
}
pub trait Writer: Client {
async fn write_single_coil(&mut self, _: Address, _: Coil) -> Result<Result<(), Exception>, Error>;
}
I saw in the implementation (let's say read_discrete_inputs
) that you check if the Response
is the good type, isn't this case unreachable
? Or it can be seen as a wrong modbus server implementation ?
Because if we have the Result<Vec<Coil>, Exception>
, the implementation of read_discrete_inputs
would look like:
async fn read_discrete_inputs<'a>(&'a mut self, addr: Address, cnt: Quantity) -> Result<Result<Vec<Coil>, Exception>, Error> {
let rsp = self.client.call(Request::ReadDiscreteInputs(addr, cnt)).await?;
match rsp {
Ok(Response::ReadDiscreteInputs(mut coils)) => {
debug_assert!(coils.len() >= cnt.into());
coils.truncate(cnt.into());
Ok(Ok(coils))
},
Err(ExceptionResponse(_, exception)) => Ok(Err(exception)),
Ok(_) => unreachable!(), // fatal error, modbus-server is not returning valid response
}
}
I don't know if it's a better idea to abort if the response is invalid, or return an Error, but the Exception should come from the server side, not the client. But since we follow the spec, this use case "should" never arise.
Server
Thanks to your server trait, we don't need to change anything because From<Result<Response, ExceptionResponse>>
is already implemented for OptionalResponsePdu
.
Pending questions
How to handle the call
function in src/service/{tcp/rtu}.rs
in order to return the nested errors ?
Feedback
What do you think ?