I've made a basic extension method to add retry functionality to my HttpClient.PostAsync
:
public static async Task<HttpResponseMessage> PostWithRetryAsync(this HttpClient httpClient, Uri uri, HttpContent content, int maxAttempts, Action<int> logRetry)
{
if (maxAttempts < 1)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max number of attempts cannot be less than 1.");
var attempt = 1;
while (attempt <= maxAttempts)
{
if (attempt > 1)
logRetry(attempt);
try
{
var response = await httpClient.PostAsync(uri, content).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return response;
}
catch (HttpRequestException)
{
++attempt;
if (attempt > maxAttempts)
throw;
}
}
}
The above code gives me the following error:
Error CS0161 'HttpClientExtensions.PostWithRetryAsync(HttpClient, Uri, HttpContent, int, Action)': not all code paths return a value.
If I add throw new InvalidOperationException()
at the end (or return null
for that matter), the error goes away as expected. What I'd really like to know is: is there any code path that actually exits this method without either a value being returned or an exception being thrown? I can't see it. Do I know more than the compiler in this case, or is it the other way around?
The simple reason is that the compiler has to be able to statically verify that all execution flow paths end up with a return statement (or an exception).
Let's look at your code, it contains:
while
loopwhile
loop, with the return
statement embeddedreturn
statement after the loopSo basically the compiler has to verify these things:
while
loop is actually executedreturn
statement is always executedThe compiler is simply not able to verify this.
Let's try a very simple example:
public int Test()
{
int a = 1;
while (a > 0)
return 10;
}
This trivial example will generate the exact same error:
CS0161 'Test()': not all code paths return a value
So the compiler is not able to deduce that because of these facts:
a
is a local variable (meaning that only local code can impact it)a
has an initial value of 1
, and is never changeda
variable is greater than zero (which it is), the return
statement is reachedthen the code will always return the value 10.
Now look at this example:
public int Test()
{
const int a = 1;
while (a > 0)
return 10;
}
Only difference is that I made a
a const
. Now it compiles, but this is because the optimizer is now able to remove the whole loop, the final IL is just this:
Test:
IL_0000: ldc.i4.s 0A
IL_0002: ret
The whole while
loop and local variable is gone, all is left is just this:
return 10;
So clearly the compiler does not look at variable values when it statically analyzes these things. The cost of implementing this feature and getting it right probably outweighs the effect or the downside of not doing it. Remember that "Every feature starts out in the hole by 100 points, which means that it has to have a significant net positive effect on the overall package for it to make it into the language.".
So yes, this is definitely a case where you know more about the code than the compiler.
Just for completeness, let's look at all the ways your code can flow:
maxAttempts
is less than 1while
-loop since attempt
is 1 and maxAttempts
is at least 1.try
statement throws a HttpRequestException
then attempt
is incremented and if still less than or equal to maxAttempts
the while
-loop will do another iteration. If it is now bigger than maxAttempts
the exception will bubble up.So basically, this code can be said to always end up either throwing an exception or return, but the compiler is not able to statically verify this.
Since you have embedded the escape hatch (attempt > maxAttempts
) in two places, both as a criteria for the while
-loop, and additionally inside the catch
block I would simplify the code by just removing it from the while
-loop:
while (true)
{
...
if (attempt > maxAttempts)
throw;
...
}
Since you're guaranteed to run the while
-loop at least once, and that it will actually be the catch
block that exits it, just formalize that and the compiler will again be happy.
Now the flow control looks like this:
while
loop will always execute (or we have already thrown an exception)while
loop will never terminate (no break
inside, so no need for any code after the loop)The only possible way to exit the loop is either an explicit return
or an exception, neither of which the compiler has to verify any more because the focus of this particular error message is to flag that there is potentially a way to escape the method without an explicit return
. Since there is no way to escape the method accidentally any more the rest of the checks can simply be skipped.
Even this method will compile:
public int Test()
{
while (true)
{
}
}