Skip to content

Commit 0c41166

Browse files
authored
Merge pull request #772 from shiftstack/retry-quota-errors
errors: treat Neutron quota-exceeded 409 errors as retryable
2 parents 7b8ce24 + f29a2d4 commit 0c41166

2 files changed

Lines changed: 130 additions & 2 deletions

File tree

internal/util/errors/errors.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package errors
1818

1919
import (
20+
"bytes"
2021
"errors"
2122
"fmt"
2223
"net/http"
@@ -59,16 +60,35 @@ func (e noMatchesError) Is(err error) bool {
5960
//
6061
// Non-HTTP errors from gophercloud (e.g. client-side validation such as
6162
// banned value_spec keys), 409 Conflict, and 501 Not Implemented are not
62-
// retryable.
63+
// retryable. The exception is Neutron quota-exceeded errors, which are
64+
// returned as 409 but are retryable because quota can free up without spec
65+
// changes.
6366
func IsRetryable(err error) bool {
64-
if IsConflict(err) || IsNotImplementedError(err) {
67+
if IsConflict(err) {
68+
// Neutron returns 409 for quota-exceeded errors, but these are
69+
// retryable because quota can free up without spec changes.
70+
return isNeutronQuotaError(err)
71+
}
72+
73+
if IsNotImplementedError(err) {
6574
return false
6675
}
6776

6877
var errUnexpectedResponseCode gophercloud.ErrUnexpectedResponseCode
6978
return errors.As(err, &errUnexpectedResponseCode)
7079
}
7180

81+
// isNeutronQuotaError returns true if err is an HTTP error response whose
82+
// body indicates a Neutron quota-exceeded condition. Neutron returns quota
83+
// errors as 409 Conflict with an "OverQuota" type in the response body.
84+
func isNeutronQuotaError(err error) bool {
85+
var errUnexpectedResponseCode gophercloud.ErrUnexpectedResponseCode
86+
if !errors.As(err, &errUnexpectedResponseCode) {
87+
return false
88+
}
89+
return bytes.Contains(errUnexpectedResponseCode.Body, []byte("OverQuota"))
90+
}
91+
7292
// IsNotFound returns true if err indicates the requested OpenStack resource
7393
// was not found (HTTP 404 or gophercloud's ErrResourceNotFound).
7494
func IsNotFound(err error) bool {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
Copyright The ORC Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package errors
18+
19+
import (
20+
"fmt"
21+
"net/http"
22+
"testing"
23+
24+
"github.com/gophercloud/gophercloud/v2"
25+
)
26+
27+
func newHTTPError(statusCode int, body string) error {
28+
return gophercloud.ErrUnexpectedResponseCode{
29+
Actual: statusCode,
30+
Body: []byte(body),
31+
}
32+
}
33+
34+
func TestIsRetryable(t *testing.T) {
35+
tests := []struct {
36+
name string
37+
err error
38+
want bool
39+
}{
40+
{
41+
name: "nil error is not retryable",
42+
err: nil,
43+
want: false,
44+
},
45+
{
46+
name: "non-HTTP error is not retryable",
47+
err: fmt.Errorf("some client-side validation error"),
48+
want: false,
49+
},
50+
{
51+
name: "409 Conflict is not retryable",
52+
err: newHTTPError(http.StatusConflict, `{"NeutronError": {"type": "IpAddressInUse"}}`),
53+
want: false,
54+
},
55+
{
56+
name: "409 Conflict with Neutron OverQuota is retryable",
57+
err: newHTTPError(http.StatusConflict, `{"NeutronError": {"type": "OverQuota", "message": "Quota exceeded for resources: port."}}`),
58+
want: true,
59+
},
60+
{
61+
name: "501 Not Implemented is not retryable",
62+
err: newHTTPError(http.StatusNotImplemented, ""),
63+
want: false,
64+
},
65+
{
66+
name: "400 Bad Request is retryable",
67+
err: newHTTPError(http.StatusBadRequest, `{"NeutronError": {"type": "BadRequest"}}`),
68+
want: true,
69+
},
70+
{
71+
name: "403 Forbidden is retryable",
72+
err: newHTTPError(http.StatusForbidden, ""),
73+
want: true,
74+
},
75+
{
76+
name: "500 Internal Server Error is retryable",
77+
err: newHTTPError(http.StatusInternalServerError, ""),
78+
want: true,
79+
},
80+
{
81+
name: "503 Service Unavailable is retryable",
82+
err: newHTTPError(http.StatusServiceUnavailable, ""),
83+
want: true,
84+
},
85+
{
86+
name: "wrapped non-HTTP error is not retryable",
87+
err: fmt.Errorf("wrapping: %w", fmt.Errorf("banned key")),
88+
want: false,
89+
},
90+
{
91+
name: "wrapped 409 is not retryable",
92+
err: fmt.Errorf("wrapping: %w", newHTTPError(http.StatusConflict, "")),
93+
want: false,
94+
},
95+
{
96+
name: "wrapped 409 with OverQuota is retryable",
97+
err: fmt.Errorf("wrapping: %w", newHTTPError(http.StatusConflict, `{"NeutronError": {"type": "OverQuota"}}`)),
98+
want: true,
99+
},
100+
}
101+
for _, tt := range tests {
102+
t.Run(tt.name, func(t *testing.T) {
103+
if got := IsRetryable(tt.err); got != tt.want {
104+
t.Errorf("IsRetryable() = %v, want %v", got, tt.want)
105+
}
106+
})
107+
}
108+
}

0 commit comments

Comments
 (0)