Errors and retries
Everything that can go wrong is one of five cases of Verdict4sError, all of
which live in verdict4s-core so you can match on them without depending on
cats-effect or http4s.
import com.softinio.verdict4s.Verdict4sError
import com.softinio.verdict4s.algebra.ApiFailure
val describe: Verdict4sError => String =
case e: Verdict4sError.Api => s"the service said ${e.status}: ${e.details}"
case e: Verdict4sError.Decoding => s"unexpected response: ${e.getMessage}"
case e: Verdict4sError.Transport => s"could not reach the service: ${e.details}"
case e: Verdict4sError.Validation => s"bad question: ${e.getMessage}"
case e: Verdict4sError.Invalid => s"${e.errors.length} problems with the request"
Validation happens before the network
Validation is a single broken rule; Invalid is the accumulated set. A
request with three malformed questions reports all three at once — and never
leaves your process.
Question.noul("", "", "") // three blank fields, three errors
Classifying a failure
Matching on raw status codes is awkward: the two statuses you most often want
to treat alike, 429 and 529, are numerically unrelated, and 5xx is a
range. ApiFailure names them instead.
error.failure match
case ApiFailure.Unauthorized => refreshCredentials()
case ApiFailure.RateLimited => backOff()
case ApiFailure.Overloaded => backOff()
case other => giveUp(other)
Every Api error also carries requestId — the service's own correlation id,
which is what to quote in a support request — and retryAfter when the server
sent one.
An error body this version does not recognise is never downgraded into a decoding failure: the raw payload is preserved on the error, because it is usually the only diagnostic you have.
Retries are on by default
The API documents 429 and 529 with an explicit instruction to retry with
exponential backoff, so verdict4s does, matching the official SDKs: two
retries, 500ms doubling to a five second ceiling, 25% jitter, honouring
Retry-After, with a thirty second ceiling on the whole call.
Jitter is what stops many clients that were rate-limited together from all retrying at the same instant.
To change it:
val config = ClientConfig.default.withRetry(
RetryPolicy(maxRetries = 5, backoffMax = 30.seconds).toOption.get
)
To turn it off:
ClientConfig.default.withoutRetries
Only 408, 429 and 5xx are retried. 401, 403, 404 and 422 are not:
retrying them would just be slower failure.
Retry-After wins only when it asks for longer than the computed backoff —
waiting less than you already planned to would defeat the purpose.