Repository navigation
feat: switch to another SMTP transport when current one returns an error - #8806
Conversation
49991d8 to
cd0344e
Compare
cd0344e to
8111320
Compare
8111320 to
bc15012
Compare
| if matches!( | ||
| err, | ||
| async_smtp::error::Error::Permanent(_) | async_smtp::error::Error::Transient(_) | ||
| ) && let Err(err) = remove_success(context, transport_id).await |
There was a problem hiding this comment.
in python at least i am used to try to avoid shadowing names (here err shadows the err of the match arm it is in).
There was a problem hiding this comment.
In general, in Rust, shadowing names is considered idiomatic in order to prevent accidentally using the outer or earlier variable, and to avoid needing to come up with new names. And I can't remember any problems in core that would have been avoided by avoiding shadowing; most of the related problems are avoided by the strict static analysis, anyway.
In this concrete example, either way of writing it seems fine to me.
There was a problem hiding this comment.
I tried enabling clippy::shadow_unrelated and started fixing some reported problems there, but there are a lot of them. Maybe will open a PR. Most of them I'd say are indeed idiomatic Rust, but some shadowing in huge functions in receive_imf looks suspicious.
Python is different because it does not create a new scope for loops and ifs, and does not have a separate syntax for bindings and assignment, so you risk assigning a variable from outer scope when you actually just wanted to have a temporary variable inside of an if or loop body.
clippy also has clippy::shadow_same and clippy::shadow_reuse, these look like separate lints moved out of clippy::shadow_unrelated for something that is even less likely to be a problem.
There was a problem hiding this comment.
So connect failures fail over to other transports.
But are you sure that send failures fall through to the next transport as well, in all cases?
As soon as a send failure does not effectively move smtp to the next transport, the loop can get stuck.
I'd rather err on moving to the next transport than repeating the last one.
Not sure which loop you talk about. There is a loop over all queued messages in SMTP loop in |
bc15012 to
c9df97b
Compare
I should not have used the word loop here i guess. My point was more about the guaranteed advancing to the next transport on send failure of a message.
I don't think this is true. Here is my understanding:
It's also a problem in practice. Here is a bit of a hacky Python test (which nevertheless tests everything in context, which i think we must find ways of doing, because Rust unit or partial integration tests only go so far). It sends a small pre-message (success), then a too-large full message with postfix configured to temp-fail instead of permanently fail. Here is a transcript of running that test against your branch (it takes a while because of retries: we need time-shifting in python), and note the The full message is stuck with the first transport, and holds up all others (here: "two") 10+ minutes. Moreover, also with permanent errors there likely is a problem, see this Rust test: /// Test that a transport that returned a permanent error
/// is not tried first again on the next connection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_permanent_error_demotes_oldest_transport() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
transport::add_pseudo_transport(t, "classic@example.org").await?;
transport::add_pseudo_transport(t, "relay@chatmail.example").await?;
let transports = super::sorted_transports(t).await?;
let classic_id = transports[0].0;
let relay_id = transports[1].0;
// Sending works over the classic transport, then it starts rejecting.
super::record_success(t, classic_id).await?;
super::remove_success(t, classic_id).await?;
let transports = super::sorted_transports(t).await?;
assert_eq!(transports[0].0, relay_id, "rejecting transport is tried first again");
Ok(())
}It fails on your branch as well. Now i am not claiming that my sketch tests are to be directly integrated. I just wanted to provide something reproducible, and to prove there are problems, and to make the point that we need to find ways to test things properly and in full context. It took me 3 hours now, but doing a release cycle and getting feedback from the wild, analyzing logfiles etc. and then doing another release is way more effort, and we would struggle to advance to next stages with smtp-sending code. |
5cd7970 to
7ffe434
Compare
Yes, Anyway, since it is not about SMTP loops, but making sure that we eventually switch to the working relay, i pushed a commit that adds
So now if all relays fail, we cycle through all of them and will eventually find the working one if there is any. |
7ffe434 to
6798b2f
Compare
6798b2f to
8ea2e27
Compare
8ea2e27 to
bd09e96
Compare
bd09e96 to
f30687d
Compare
Hocuri
left a comment
There was a problem hiding this comment.
Nice to have more reliable transport selection
| if matches!( | ||
| err, | ||
| async_smtp::error::Error::Permanent(_) | async_smtp::error::Error::Transient(_) | ||
| ) && let Err(err) = record_failure(context, transport_id).await |
There was a problem hiding this comment.
Right now, we record a failure only if the server returns an error code. There seem to be more errors that warrant switching relays, like timeouts because of a very slow connection or problems with parsing the response. What about recording a failure whenever send_result.is_err()? Then we will unnecessarily switch sending relays more often, but this seems fine.
Of course, it's hard to say what's really best here, since we don't know which real-world problems occur often.
There was a problem hiding this comment.
like timeouts because of a very slow connection or problems with parsing the response
In case of timeouts while connecting we will try second relay after timeout and record success for it, this is how it works already in 2.63.0 without this PR.
Misbehaving servers may be a problem (e.g. server dying each time after getting a message without returning any response, but this is theoretical). Most realistic problem is the servers which are timing out while receiving the message, e.g. because of middlebox blackholing the connection after a certain number of bytes is sent. Doing something about this scenario would be nice, but i don't think recording failure on timeout and moving the candidate to the end of the list on a single failure is a good idea because you will have false positives each time you switch the network or lose the connection while sending a message.
| if matches!( | ||
| err, | ||
| async_smtp::error::Error::Permanent(_) | async_smtp::error::Error::Transient(_) | ||
| ) && let Err(err) = remove_success(context, transport_id).await |
There was a problem hiding this comment.
In general, in Rust, shadowing names is considered idiomatic in order to prevent accidentally using the outer or earlier variable, and to avoid needing to come up with new names. And I can't remember any problems in core that would have been avoided by avoiding shadowing; most of the related problems are avoided by the strict static analysis, anyway.
In this concrete example, either way of writing it seems fine to me.
| Ok(()) | ||
| } | ||
|
|
||
| /// Removes last remembered success for SMTP transport so another transport is tried next time. |
There was a problem hiding this comment.
| /// Removes last remembered success for SMTP transport so another transport is tried next time. | |
| /// Records a failure and removes last remembered success for | |
| /// SMTP transport so another transport is tried next time. |
| // Remote error, retry later. | ||
| info!(context, "SMTP failed to send: {:?}.", &err); | ||
|
|
||
| // Remove recorded SMTP success on any error returned by the server, but not network errors. |
There was a problem hiding this comment.
| // Remove recorded SMTP success on any error returned by the server, but not network errors. | |
| // Record failure on any error returned by the server, but not network errors. |
f30687d to
b114fb0
Compare
b114fb0 to
161f980
Compare
Classic email servers sometimes start rejecting all messages and ask the user to solve web CAPTCHA, but still allow to connect. In this case we want to switch to another SMTP server by moving the transport to the end of the list returned by smtp::sorted_transports. We disconnect after any message sending failure, so next message or resent same message will be sent over another other transport. Permanent SMTP error does not necessarily mean that switching to another server will help. For example if the user tries to send a large message, any server will reject it, and it is not a failure of the server. We do not try to distinguish between error messages, so it is expected that we may sometimes switch to another server when it is unnecessary.
161f980 to
5db5dbc
Compare
Classic email servers sometimes start rejecting all messages and ask the user to solve web CAPTCHA, but still allow to connect.
In this case we want to switch to another SMTP server by moving the transport to the end of the list returned by smtp::sorted_transports.
We disconnect after any message sending failure, so next message or resent same message will be sent over another other transport.
Permanent SMTP error does not necessarily mean that switching to another server will help.
For example if the user tries to send a large message, any server will reject it, and it is not a failure of the server.
We do not try to distinguish between error messages, so it is expected that we may
sometimes switch to another server when it is unnecessary.
Follow-up to #8771
Closes #8804