Errors and retries
Everything that can go wrong is one of six 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"
case e: Verdict4sError.Refused => s"the service declined ${e.keys.length} question(s)"
Refused only comes from OpenAI Decisions, which may decline a single question.
See refusals.
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. For OpenAI, retry-after-ms is read ahead of Retry-After.
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
Jev documents 429 and 529 with an explicit instruction to retry with
exponential backoff, so verdict4s does, for both vendors, 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. Nor is OpenAI's
insufficient_quota, which arrives as a 429 like a rate limit but means
the account has no credits, so no amount of waiting fixes it.
Each attempt is also capped by ClientConfig.requestTimeout, 10 seconds by
default. An attempt that exceeds it fails as a Transport error and is not
retried. The whole call, retries and their waits included, is capped by the
policy's totalTimeout, 30 seconds by default; a call still running when it
expires fails as a Transport error too. The two read differently: "no
response from Jev within 10 seconds" for one attempt, "gave up on Jev after
totalTimeout 30 seconds, retries included" for the whole call.
Retry-After wins only when it asks for longer than the computed backoff —
waiting less than you already planned to would defeat the purpose. Whichever
of the two sets the delay, a delay at least as long as what is left of
totalTimeout ends the retries at once, with the server's error and its
retryAfter, because the call could only time out waiting. With 15 of 30
seconds already spent, a Retry-After of 20 seconds gives up rather than
sleeping into the timeout.