ory/kratos · warning · wrapped sentinel error

ErrUnexpectedStatusCode

ErrUnexpectedStatusCode

Error message

ErrUnexpectedStatusCode wrapped: %d (unexpected status code from HIBP API)

What it means

After the HIBP range request succeeds at the transport level, fetch checks that the HTTP status is 200. Any other status code (e.g. 429 rate limit, 5xx) is wrapped in ErrUnexpectedStatusCode. HIBP only returns the hash-suffix list on 200, so any other code means the result is unusable.

Solutions

  1. Set ignore_network_errors: true (also covers this error class) so password validation degrades gracefully.
  2. Rely on the built-in hash cache (s.hashes) and ensure the instance is long-lived rather than restarted per request.
  3. Check the wrapped status code: 429 implies rate limiting — reduce request volume or add a paid HIBP key via an API proxy.
  4. Retry later; if persistent, monitor status.api.pwnedpasswords.com for outages.
Defensive patterns

Strategy: fallback

Try / catch

if errors.Is(err, strategy.ErrUnexpectedStatusCode) {
  log.WithError(err).Warn("HIBP returned non-200; skipping breach check")
  if cfg.IgnoreNetworkErrors { return allowed }
}

Prevention

When it happens

Trigger: fetch receives a response with status != 200 from the HIBP API — most commonly HTTP 429 when the unauthenticated rate limit is exceeded, or 5xx during HIBP outages.

Common situations: High-traffic signup flows hammering the HIBP API without caching, deployments sharing IPs behind NAT hitting rate limits, HIBP service degradation.

Related errors


AI-assisted analysis of ory/kratos@b86338da04 (2026-09-07). Data as JSON: /api/errors/6f36a66a84bf54b8. Report an issue: GitHub.

Appendix: source

Thrown at selfservice/strategy/password/validator.go:139

	}
	return greatestLength
}

func (s *DefaultPasswordValidator) fetch(ctx context.Context, hpw []byte, apiDNSName string) (int64, error) {
	prefix := fmt.Sprintf("%X", hpw)[0:5]
	loc := fmt.Sprintf("https://%s/range/%s", apiDNSName, prefix)
	req, err := retryablehttp.NewRequestWithContext(ctx, "GET", loc, nil)
	if err != nil {
		return 0, err
	}
	res, err := s.reg.HTTPClient(ctx, httpx.ResilientClientWithConnectionTimeout(time.Second)).Do(req)
	if err != nil {
		return 0, errors.Wrapf(ErrNetworkFailure, "%s", err)
	}
	defer func() { _ = res.Body.Close() }()

	if res.StatusCode != http.StatusOK {
		return 0, errors.Wrapf(ErrUnexpectedStatusCode, "%d", res.StatusCode)
	}

	var thisCount int64

	sc := bufio.NewScanner(res.Body)
	for sc.Scan() {
		row := sc.Text()
		result := strings.Split(strings.TrimSpace(row), ":")

		// We assume a count of 1. HIBP API sometimes responds without the
		// colon, so we just assume that the leak count is one.
		//
		// See https://github.com/ory/kratos/issues/2145
		count := int64(1)
		if len(result) == 2 {
			count, err = strconv.ParseInt(strings.ReplaceAll(result[1], ",", ""), 10, 64)
			if err != nil {
				return 0, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("Expected password hash to contain a count formatted as int but got: %s", result[1]))

View on GitHub (pinned to b86338da04)