fix(core): транзиентный 401 теперь ретраится для GET
isRetryable (core/http.go): добавлен StatusUnauthorized (401) к {429,502,503,504}.
Gateway может временно отклонять валидный JWT — без этого одиночный 401 ронял
read/plan/поллинг.
- client_test.go: юнит-тест TestIsRetryable (401/429/502/503/504 = true; 400/403/404/500 = false).
- ARCHITECTURE.md: раздел API Resilience приведён к фактическому поведению
(401 реализован; POST не ретраится намеренно).
Проверено: go build ./... OK; go test ./internal/core/... -short OK.
This commit is contained in:
@@ -123,9 +123,11 @@ Each operation has a kind:
|
||||
|
||||
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
|
||||
Gateway may temporarily reject valid JWT tokens.
|
||||
- **CURRENTLY NOT IMPLEMENTED** — `isRetryable` (`core/http.go`) retries only
|
||||
{429, 502, 503, 504}, NOT 401, and only for GET. This is a known gap versus the
|
||||
intent above; fix in `core/http.go` `isRetryable`.
|
||||
- Implemented: `isRetryable` (`core/http.go`) includes 401 alongside {429, 502, 503, 504};
|
||||
retry applies to GET requests only.
|
||||
- Network errors and HTTP retryable statuses are retried for **GET only**. POST is not
|
||||
retried (a blind retry of the creating `POST /instanceOperations` could duplicate an
|
||||
operation — see open question on idempotency).
|
||||
|
||||
### Generated Code Resilience
|
||||
|
||||
|
||||
@@ -52,6 +52,33 @@ func TestNormalizeValue_TrimSpace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryable(t *testing.T) {
|
||||
retryable := []int{
|
||||
http.StatusUnauthorized, // 401 — транзиентный отказ Gateway
|
||||
http.StatusTooManyRequests, // 429
|
||||
http.StatusServiceUnavailable, // 503
|
||||
http.StatusBadGateway, // 502
|
||||
http.StatusGatewayTimeout, // 504
|
||||
}
|
||||
for _, code := range retryable {
|
||||
if !isRetryable(code) {
|
||||
t.Errorf("expected isRetryable(%d)=true", code)
|
||||
}
|
||||
}
|
||||
|
||||
notRetryable := []int{
|
||||
http.StatusBadRequest, // 400
|
||||
http.StatusForbidden, // 403
|
||||
http.StatusNotFound, // 404
|
||||
http.StatusInternalServerError, // 500
|
||||
}
|
||||
for _, code := range notRetryable {
|
||||
if isRetryable(code) {
|
||||
t.Errorf("expected isRetryable(%d)=false", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== waitForOperationFinish tests =====
|
||||
|
||||
func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) {
|
||||
|
||||
@@ -117,8 +117,11 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
|
||||
}
|
||||
|
||||
// isRetryable returns true for transient HTTP errors that can be retried.
|
||||
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
|
||||
// «API Resilience»). Ретраится только для GET (см. условие в doRequest).
|
||||
func isRetryable(statusCode int) bool {
|
||||
return statusCode == http.StatusTooManyRequests || // 429
|
||||
return statusCode == http.StatusUnauthorized || // 401
|
||||
statusCode == http.StatusTooManyRequests || // 429
|
||||
statusCode == http.StatusServiceUnavailable || // 503
|
||||
statusCode == http.StatusBadGateway || // 502
|
||||
statusCode == http.StatusGatewayTimeout // 504
|
||||
|
||||
Reference in New Issue
Block a user