diff --git a/README.md b/README.md index 7d640b7..6c5314b 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ graph TD Each built-in code lives on its own type's companion object (e.g. `Succeeded.CREATED`, `Restricted.DENIED`), not on `Codes` — this keeps autocomplete scoped, typing `Restricted.` shows only `Restricted`'s own members. `Codes` is just the aggregate list + lookup layer over those instances; using it, or the codes at all, is optional — you can construct any `Passed`/`Failed` subtype directly for domain-specific outcomes. -Some examples: `SUCCESS`, `CREATED`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `UNEXPECTED` — see [`Codes.kt`](kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt) and [`Status.kt`](kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt) for the full registry (54 codes across 8 categories). +Some examples: `SUCCESS`, `CREATED`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `UNEXPECTED` — see [`Codes.kt`](kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt) and [`Status.kt`](kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt) for the full registry (57 codes across 8 categories). A few pairs worth distinguishing on sight: @@ -270,7 +270,7 @@ grpc.toCode(Restricted.DENIED) // 7 grpc.toStatus(9)?.name // "PRECONDITION_FAILED" — deterministic canonical winner for that code ``` -One gap worth knowing: gRPC's `ABORTED` (10) has no dedicated `Status`, so `toStatus(10)` returns `null` — callers need to handle that explicitly. +Every gRPC code (0-16) resolves to a `Status` — the one gap (`ABORTED`) closed once `Unserved.ABORTED` was added. ## 🧾 Err & Checked diff --git a/assets/kiit-codes.drawio b/assets/kiit-codes.drawio index 52848d0..17b0f8b 100644 --- a/assets/kiit-codes.drawio +++ b/assets/kiit-codes.drawio @@ -1,11 +1,11 @@ - + - + @@ -190,7 +190,7 @@ - + @@ -242,13 +242,13 @@ - + - + - + @@ -257,33 +257,33 @@ - + - + - + - + - + - + - + - + @@ -314,13 +314,13 @@ - + - + - + @@ -362,7 +362,7 @@ - + @@ -371,19 +371,19 @@ - + - - + + - + - + - + @@ -391,6 +391,15 @@ + + + + + + + + + diff --git a/assets/kiit-codes.png b/assets/kiit-codes.png index 6ed0327..e7e925f 100644 Binary files a/assets/kiit-codes.png and b/assets/kiit-codes.png differ diff --git a/kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt b/kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt index deb2895..748c2a6 100644 --- a/kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt +++ b/kiit-codes/src/commonMain/kotlin/kiit/codes/Codes.kt @@ -49,7 +49,7 @@ object Codes { Rejected.PRECONDITION_FAILED, Rejected.EXPIRED, Rejected.GONE, Unserved.UNEXPECTED, Unserved.UNSUPPORTED, Unserved.TIMEOUT, Unserved.RATE_LIMITED, Unserved.RESOURCE_LIMITED, Unserved.UNREACHABLE, Unserved.UNDER_MAINTENANCE, - Unserved.INTERNAL, Unserved.DATA_LOSS, + Unserved.INTERNAL, Unserved.DATA_LOSS, Unserved.DEGRADED, Unserved.LEGAL_BLOCK, Unserved.ABORTED, ) private val byId: Map = all.associateBy { it.id } @@ -152,6 +152,7 @@ open class CodesToHttp( // same axis as RATE_LIMITED — HTTP doesn't distinguish the two Unserved.RESOURCE_LIMITED.id to 429, Unserved.UNEXPECTED.id to 500, + Unserved.LEGAL_BLOCK.id to 451, ) /** @@ -181,7 +182,7 @@ open class CodesToHttp( * Category -> gRPC default: Passed (all) -> 0 (OK) Restricted -> 7 (PERMISSION_DENIED) * Invalid -> 3 (INVALID_ARGUMENT) Rejected -> 9 (FAILED_PRECONDITION) Unserved -> 13 (INTERNAL) * - * gRPC's `ABORTED` (10) has no dedicated [Status]; [toStatus] returns null for it. + * gRPC's `ABORTED` (10) maps to [Unserved.ABORTED] — previously an honest `null` gap, now closed. */ open class CodesToGrpc( private val overrides: Map = DEFAULT_OVERRIDES, @@ -234,6 +235,10 @@ open class CodesToGrpc( Unserved.DATA_LOSS.id to 15, // RESOURCE_EXHAUSTED — widely used real-world convention, not an official mapping Invalid.PAYLOAD_TOO_LARGE.id to 8, + // exact match, closes the previously honest null gap at 10 + Unserved.ABORTED.id to 10, + // DEGRADED and LEGAL_BLOCK have no closer gRPC equivalent — fall through to + // Unserved's own category default (13, INTERNAL) ) /** One canonical winner per gRPC code with more than one resolving [Status] — see [toStatus]. */ diff --git a/kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt b/kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt index 7402745..aa44273 100644 --- a/kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt +++ b/kiit-codes/src/commonMain/kotlin/kiit/codes/Status.kt @@ -123,7 +123,19 @@ sealed class Passed : Status { is Information -> "Information" } - /** Operation's primary purpose completed (e.g. a value was created, fetched, updated). */ + /** Runtime-accessible version of each category's meaning — see the subtypes' own KDoc for detail. */ + val groupDescription: String + get() = + when (this) { + is Succeeded -> "The operation's primary purpose was completed successfully." + is Pending -> "The operation was accepted, but has not yet fully resolved." + is Excluded -> + "The item was left out of the operation's normal output, never processed, " + + "processed and discarded, or excluded for another reason." + is Information -> "An informational or metadata response; no primary operation was performed." + } + + /** See [Passed.groupDescription] for this category's definition. */ data class Succeeded( override val name: String, override val message: String, @@ -152,7 +164,7 @@ sealed class Passed : Status { } } - /** Operation accepted but not yet fully processed (e.g. queued, waiting, confirmed). */ + /** See [Passed.groupDescription] for this category's definition. */ data class Pending( override val name: String, override val message: String, @@ -194,10 +206,10 @@ sealed class Passed : Status { } /** - * Item was excluded from the operation's normal output — not processed at all (e.g. - * SKIPPED), processed then discarded (e.g. DISCARDED), or omitted for any other reason. - * The distinction is carried by [name], not by separate types — see [Excluded.SKIPPED] - * and [Excluded.DISCARDED]. + * See [Passed.groupDescription] for this category's definition. + * + * The distinction between e.g. [Excluded.SKIPPED] and [Excluded.DISCARDED] is carried by + * [name], not by separate types. */ data class Excluded( override val name: String, @@ -234,10 +246,7 @@ sealed class Passed : Status { } } - /** - * Informational / metadata response — no primary operation was performed. - * E.g. HELP, ABOUT, VERSION output from a CLI command. - */ + /** See [Passed.groupDescription] for this category's definition. */ data class Information( override val name: String, override val message: String, @@ -288,7 +297,21 @@ sealed class Failed : Status { is Unserved -> "Unserved" } - /** Security / access-control failure — the caller is not permitted to perform this action. */ + /** Runtime-accessible version of each category's meaning — see the subtypes' own KDoc for detail. */ + val groupDescription: String + get() = + when (this) { + is Restricted -> "An access-control failure — the caller is not permitted to perform this action." + is Invalid -> + "The request as given cannot be satisfied — malformed input, invalid values, " + + "or an unknown route." + is Rejected -> "A known, expected business-rule failure — understood and permitted, but refused." + is Unserved -> + "The request is valid and permitted, but cannot be serviced right now, " + + "for reasons unrelated to what was sent." + } + + /** See [Failed.groupDescription] for this category's definition. */ data class Restricted( override val name: String, override val message: String, @@ -321,7 +344,7 @@ sealed class Failed : Status { } } - /** The request as given cannot be satisfied — malformed input, invalid values, or not found. */ + /** See [Failed.groupDescription] for this category's definition. */ data class Invalid( override val name: String, override val message: String, @@ -359,7 +382,7 @@ sealed class Failed : Status { } } - /** A known, expected business-rule failure — understood and handled by the caller. */ + /** See [Failed.groupDescription] for this category's definition. */ data class Rejected( override val name: String, override val message: String, @@ -402,9 +425,11 @@ sealed class Failed : Status { } /** - * The request is valid and permitted, but cannot be serviced right now for reasons unrelated - * to what was sent — capacity, timeout, an unsupported capability, planned - * maintenance, or a genuinely unexpected/unhandled failure (see [Unserved.UNEXPECTED]). + * See [Failed.groupDescription] for this category's definition. + * + * E.g. capacity, timeout, an unsupported capability, planned maintenance, a degraded or + * aborted dependency, a legal/regulatory block, or a genuinely unexpected/unhandled failure + * (see [Unserved.UNEXPECTED]). */ data class Unserved( override val name: String, @@ -442,6 +467,24 @@ sealed class Failed : Status { ) val DATA_LOSS = Unserved("DATA_LOSS", "Unrecoverable data loss or corruption occurred.", origin = StatusConstants.KIIT) + val DEGRADED = + Unserved( + "DEGRADED", + "This dependency is degraded; some calls may be refused.", + origin = StatusConstants.KIIT, + ) + val LEGAL_BLOCK = + Unserved( + "LEGAL_BLOCK", + "Access is restricted due to legal or regulatory requirements.", + origin = StatusConstants.KIIT, + ) + val ABORTED = + Unserved( + "ABORTED", + "The operation was aborted before it could complete; retrying may succeed.", + origin = StatusConstants.KIIT, + ) } } } diff --git a/kiit-codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt b/kiit-codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt index 4b3886d..19d883e 100644 --- a/kiit-codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt +++ b/kiit-codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt @@ -81,6 +81,16 @@ class CodesTest { assertTrue(Excluded.DISQUALIFIED is Passed.Excluded) } + @Test + fun degradedLegalBlockAndAbortedAreUnserved() { + assertFalse(Unserved.DEGRADED.success) + assertFalse(Unserved.LEGAL_BLOCK.success) + assertFalse(Unserved.ABORTED.success) + assertTrue(Unserved.DEGRADED is Failed.Unserved) + assertTrue(Unserved.LEGAL_BLOCK is Failed.Unserved) + assertTrue(Unserved.ABORTED is Failed.Unserved) + } + @Test fun everyBuiltInCodeHasKiitOrigin() { assertTrue(Codes.all.all { it.origin == StatusConstants.KIIT }) @@ -222,6 +232,16 @@ class CodesToHttpTest { assertEquals(500, http.toCode(Unserved.UNEXPECTED)) } + @Test fun overrideLegalBlock() { + assertEquals(451, http.toCode(Unserved.LEGAL_BLOCK)) + } + + @Test fun categoryDefaultCoversDegradedAndAborted() { + // Neither has a closer HTTP equivalent — deliberately fall through to Unserved's default. + assertEquals(503, http.toCode(Unserved.DEGRADED)) + assertEquals(503, http.toCode(Unserved.ABORTED)) + } + /** * A custom, unregistered status still resolves via its category's default rather than a * guessed/literal fallback. @@ -396,6 +416,9 @@ class CodesToGrpcTest { @Test fun categoryDefaultUnservedIsInternal() { // UNSUPPORTED is overridden to 12 (see overrideUnsupported) and no longer falls here. assertEquals(13, grpc.toCode(Unserved.UNDER_MAINTENANCE)) + // No closer gRPC equivalent for either — deliberately fall through to the category default. + assertEquals(13, grpc.toCode(Unserved.DEGRADED)) + assertEquals(13, grpc.toCode(Unserved.LEGAL_BLOCK)) } @Test fun overrideCancelled() { @@ -443,6 +466,11 @@ class CodesToGrpcTest { assertEquals(15, grpc.toCode(Unserved.DATA_LOSS)) } + /** Exact match — closes what used to be an honest null gap at gRPC's own ABORTED (10). */ + @Test fun overrideAborted() { + assertEquals(10, grpc.toCode(Unserved.ABORTED)) + } + /** Not an official gRPC mapping, shares RESOURCE_EXHAUSTED (8) with RATE_LIMITED deliberately. */ @Test fun overridePayloadTooLargeSharesResourceExhausted() { @@ -510,10 +538,10 @@ class CodesToGrpcTest { assertSame(Unserved.UNSUPPORTED, grpc.toStatus(12)) } - /** ABORTED (10) has no dedicated Status and nothing falls through to it by default; null. */ + /** ABORTED now maps directly to gRPC 10, closing what used to be an honest null gap. */ @Test - fun toStatus10ReturnsNullSinceAbortedHasNoDedicatedCode() { - assertNull(grpc.toStatus(10)) + fun toStatus10ResolvesToAborted() { + assertSame(Unserved.ABORTED, grpc.toStatus(10)) } @Test diff --git a/kiit-codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt b/kiit-codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt index fa2ee9d..05833e8 100644 --- a/kiit-codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt +++ b/kiit-codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt @@ -81,6 +81,32 @@ class StatusTest { assertEquals("Unserved", Failed.Unserved("U", "U").group) } + // ------------------------------------------------------------------------- + // groupDescription — runtime-accessible version of each category's meaning + // ------------------------------------------------------------------------- + + @Test + fun groupDescriptionIsNonBlankAndDistinctForAllSubtypes() { + val descriptions = + listOf( + Passed.Succeeded("S", "S").groupDescription, + Passed.Pending("P", "P").groupDescription, + Passed.Excluded("F", "F").groupDescription, + Passed.Information("N", "N").groupDescription, + Failed.Restricted("R", "R").groupDescription, + Failed.Invalid("I", "I").groupDescription, + Failed.Rejected("E", "E").groupDescription, + Failed.Unserved("U", "U").groupDescription, + ) + assertTrue(descriptions.all { it.isNotBlank() }) + assertEquals(descriptions.size, descriptions.toSet().size) + } + + @Test + fun groupDescriptionIsConsistentAcrossInstancesOfTheSameSubtype() { + assertEquals(Failed.Restricted("A", "A").groupDescription, Failed.Restricted("B", "B").groupDescription) + } + // ------------------------------------------------------------------------- // id — "$origin.$name", derived, usable as a map/lookup key // -------------------------------------------------------------------------