SagaSaga
Bcrypt

Error handling

hash and verify return an Err branch of the Result type, carrying a BcryptError. This page covers what the variants mean and how to handle them.

The BcryptError type

pub type BcryptError =
  | InvalidCost Int     # cost was outside the allowed 4..31 range
  | InvalidHash String  # the hash string passed to verify was malformed
  | HashError String    # any other underlying failure (I/O, RNG, etc.)
  deriving (Show, Eq, Debug)

It derives Show, Eq, and Debug, so you can interpolate it in messages (via show/debug), compare values, and assert on them directly in tests.

When each variant occurs

InvalidCost n comes from hash, when the Config cost is outside bcrypt's valid range of 4 to 31. n is the offending value you passed. This is the only way hash fails in normal use:

hash "pw" (hasher |> with_cost 99)   # Err (InvalidCost 99)
hash "pw" (hasher |> with_cost 2)    # Err (InvalidCost 2)

InvalidHash msg comes from verify, when the hash string can't be parsed — a bad prefix, wrong length, or invalid base64. msg is a human-readable description. This means the hash is broken, not that the password is wrong:

verify "pw" "not-a-real-hash"   # Err (InvalidHash "Invalid hash: not-a-real-hash")

HashError msg is the catch-all for anything else the underlying implementation reports (I/O, RNG). You are unlikely to see it in practice; handle it defensively as an unexpected internal failure.

Handling failures

Match on the Result and, where it matters, on the specific variant. A typical login check that logs when the stored hash is broken (the needs {Log} reflects the log! calls):

import Bcrypt (verify, InvalidHash, HashError)

fun check_login : String -> String -> Bool needs {Log}
check_login attempt stored = case verify attempt stored {
  Ok matches           -> matches          # True or False — the real answer
  Err (InvalidHash _)  -> {
    log! "stored hash is corrupted for this user"
    False
  }
  Err (HashError msg)  -> {
    log! $"bcrypt internal error: {msg}"
    False
  }
}

(If you don't need logging, drop the Err bodies down to plain False and the needs {Log} clause with them.)

Two things to keep straight:

  • Ok False is not an error. A wrong password is a successful verification that returns False. Only reach for the Err arms when the hash itself is the problem. Collapsing the two loses a signal you want: a spike in Err (InvalidHash _) means your stored data is broken, while Ok False is just a bad login attempt.
  • InvalidCost can't come from verify. Verification reads the cost out of the hash, so the only failure it produces is InvalidHash (or, rarely, HashError). You can safely omit an InvalidCost arm when matching on a verify result — though a catch-all Err _ is fine if you would rather not enumerate.

Testing against errors

Because BcryptError derives Eq and Debug, you can assert on exact errors:

import Std.Test (assert_eq)
import Bcrypt (hash, hasher, with_cost, InvalidCost)

assert_eq (hash "pw" (hasher |> with_cost 99)) (Err (InvalidCost 99))

Or match the variant when the message is implementation-defined and you don't want to pin it:

case verify "pw" "garbage" {
  Err (InvalidHash _) -> assert! True ""
  other -> assert! False $"expected InvalidHash, got {debug other}"
}