|
| 1 | +package org.foedusprogramme.alexandrite.internal.http |
| 2 | + |
| 3 | +import java.io.IOException |
| 4 | +import java.net.http.HttpHeaders |
| 5 | +import java.time.Instant |
| 6 | +import java.time.ZonedDateTime |
| 7 | +import java.time.format.DateTimeFormatter |
| 8 | +import java.time.format.DateTimeParseException |
| 9 | +import kotlin.time.Duration |
| 10 | +import kotlin.time.Duration.Companion.milliseconds |
| 11 | +import kotlin.time.Duration.Companion.seconds |
| 12 | +import kotlin.time.toKotlinDuration |
| 13 | + |
| 14 | +/** An HTTP call that failed. */ |
| 15 | +public class HttpException( |
| 16 | + public val kind: HttpFailureKind, |
| 17 | + message: String, |
| 18 | + /** The response's status, null when no response came. */ |
| 19 | + public val status: Int? = null, |
| 20 | + /** How long the server asked to wait, null when it did not say. */ |
| 21 | + public val retryAfter: Duration? = null, |
| 22 | + /** The server's id of the request. */ |
| 23 | + public val requestId: String? = null, |
| 24 | + /** The start of the response body, its secrets masked, null when there is none. */ |
| 25 | + public val body: String? = null, |
| 26 | + /** Whether the response's body had started when the call failed. */ |
| 27 | + public val afterResponse: Boolean = false, |
| 28 | + cause: Throwable? = null, |
| 29 | +) : IOException(message, cause) |
| 30 | + |
| 31 | +/** What kind of failure an [HttpException] is, and whether the same request may succeed when sent again. */ |
| 32 | +public enum class HttpFailureKind(public val retryable: Boolean) { |
| 33 | + AUTHENTICATION(false), |
| 34 | + PERMISSION_DENIED(false), |
| 35 | + QUOTA_EXHAUSTED(false), |
| 36 | + NOT_FOUND(false), |
| 37 | + REQUEST_TOO_LARGE(false), |
| 38 | + INVALID_REQUEST(false), |
| 39 | + RATE_LIMITED(true), |
| 40 | + OVERLOADED(true), |
| 41 | + SERVER_ERROR(true), |
| 42 | + |
| 43 | + /** A server too slow for the call's timeouts or a 408 / 504 status. */ |
| 44 | + TIMEOUT(true), |
| 45 | + |
| 46 | + /** A server that cannot be reached or a connection that breaks. */ |
| 47 | + CONNECTION(true), |
| 48 | + |
| 49 | + /** A response that breaks HTTP or what the call expects, such as a redirect. */ |
| 50 | + PROTOCOL(false), |
| 51 | + ; |
| 52 | + |
| 53 | + public companion object { |
| 54 | + /** The kind of a response with [status], which is no 2xx status. */ |
| 55 | + public fun of(status: Int): HttpFailureKind = when (status) { |
| 56 | + 401 -> AUTHENTICATION |
| 57 | + 402 -> QUOTA_EXHAUSTED |
| 58 | + 403 -> PERMISSION_DENIED |
| 59 | + 404 -> NOT_FOUND |
| 60 | + 408, 504 -> TIMEOUT |
| 61 | + 409 -> SERVER_ERROR |
| 62 | + 413 -> REQUEST_TOO_LARGE |
| 63 | + 429 -> RATE_LIMITED |
| 64 | + 503, 529 -> OVERLOADED |
| 65 | + in 400..499 -> INVALID_REQUEST |
| 66 | + in 500..599 -> SERVER_ERROR |
| 67 | + else -> PROTOCOL |
| 68 | + } |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +/** The wait that [headers] ask for at [now] in `retry-after-ms` or `retry-after`, null when they ask for none. */ |
| 73 | +public fun retryAfter(headers: HttpHeaders, now: Instant): Duration? { |
| 74 | + headers.firstValue("retry-after-ms").orElse(null)?.trim()?.toDoubleOrNull() |
| 75 | + ?.takeIf { it.isFinite() && it >= 0 } |
| 76 | + ?.let { return it.milliseconds } |
| 77 | + val value = headers.firstValue("retry-after").orElse(null)?.trim() ?: return null |
| 78 | + value.toDoubleOrNull()?.let { return if (it.isFinite() && it >= 0) it.seconds else null } |
| 79 | + return try { |
| 80 | + val at = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() |
| 81 | + java.time.Duration.between(now, at).coerceAtLeast(java.time.Duration.ZERO).toKotlinDuration() |
| 82 | + } catch (e: DateTimeParseException) { |
| 83 | + null |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +/** The server's id of the request in [headers], null when they hold none. */ |
| 88 | +public fun requestId(headers: HttpHeaders): String? = REQUEST_ID_HEADERS.firstNotNullOfOrNull { name -> |
| 89 | + headers.firstValue(name).orElse(null)?.takeIf { it.isNotBlank() } |
| 90 | +} |
| 91 | + |
| 92 | +private val REQUEST_ID_HEADERS = listOf("x-request-id", "request-id", "x-amzn-requestid", "x-amz-request-id") |
0 commit comments