Iden Tumuhirwe
All writing

June 18, 2026 · 6 min read

The making of a library I built to stop parsing DRF errors by hand

Kotlin · Open Source · Django REST

I've written the same fifteen lines of error-parsing code in enough Kotlin and Android projects against Django REST Framework backends that I stopped trusting myself to get it right from memory. DRF is flexible about how it shapes a validation error, which is great for the Python side and mildly hostile to whatever's consuming it. A plain serializer error looks like {"email": ["Enter a valid email address."]}. A nested serializer wraps that in another object. A list serializer wraps it in an array with gaps for the rows that passed. Auth failures and permission checks skip all of that and just hand you a top-level detail string. And every client codebase I'd touched had its own slightly different, slightly buggy version of the code that untangles this.

So I built DRF Error Handler, a small Kotlin/JVM library that does the untangling once, and spent real effort making sure it never becomes the thing that crashes your error-handling path.

One tree walk, four shapes

Underneath, it's one recursive function walking a parsed JSON tree and building up field paths as it goes. Dot for nesting, brackets for list indexes:

if (value is JsonArray && value.all { it is JsonPrimitive && it.isString }) {
    value.forEach { errorList.add(DRFError(field = nextPath, message = it.jsonPrimitive.content)) }
} else {
    extractErrors(value, nextPath, errorList, depth + 1)
}

{"author": {"email": [...]}} becomes a DRFError with field author.email. A list serializer error on the second row becomes [1].title. It's a small function and it took a handful of iterations to get the path-building right, because the obvious first version treated non_field_errors and top-level detail as just another key, which meant they showed up with a field name attached, wrong, they're global errors, they don't belong to any one input.

There's also a hard depth cap. A hundred levels of nesting is not a real DRF response, but it is exactly the kind of pathological input you want to fail predictably on rather than find out the hard way, so past that depth the walk just stops and reports "too deeply nested" instead of digging further.

The bug I didn't expect: Python's own error objects leaking through

The one that actually surprised me. DRF's internal error type is ErrorDetail, and its Python repr() is ErrorDetail("This field is required.", code="required"). That's not supposed to reach a client. But if something upstream logs an error object, or re-serializes it through a code path that doesn't call DRF's own JSON renderer, that literal Python repr can end up sitting inside what's otherwise a JSON body, and it's not valid JSON. Standard parsing just fails on it.

I ended up writing a regex to catch that shape and rewrite it into a real JSON string before parsing even starts:

private val errorDetailRegex =
    """ErrorDetail\(\s*(["'])((?:\\.|(?!\1).)*?)\1\s*,\s*code=(["'])((?:\\.|(?!\3).)*?)\3\s*\)""".toRegex()

It has to handle both quote styles, because Python doesn't care which one you use, and it has to not stop early at an escaped quote inside the message itself. Getting the backreference right so \' inside a single-quoted string doesn't terminate the match early took more attempts than I'd like to admit. Once it matches, the captured message still has Python-style escaping (\n, \t, escaped quotes) rather than JSON escaping, so there's a second small hand-written decoder that walks the string character by character and turns those into the real characters before handing it back as a proper JSON string literal.

It's a strange thing to spend an evening on: writing a parser whose entire job is to patch over another parser's internal debug representation leaking into production. But it's exactly the kind of leak that's easy to wave off as "that'll never actually reach a client" right up until it does, and I wasn't going to ship a library claiming to handle DRF errors that didn't account for it.

HTML shows up too, uninvited

The other payload shape that doesn't officially exist but absolutely happens: Django running with DEBUG=True returns a full HTML error page for a 500, not JSON. If your error handling assumes JSON, that HTML either throws or, worse, silently renders as a giant unstyled block on someone's phone screen. The library checks for HTML-looking content before it even tries JSON, pulls the <title> tag if there is one, since Django's debug pages put something like "OperationalError at /api/orders/" there, and falls back to a stripped, truncated version of the body text if there isn't. Either way you get one readable line instead of a document.

Never throw, same rule as always

By the time I got to parse() itself, I'd already decided the one thing this library is not allowed to do is throw from a networking error path. Malformed input, empty input, a payload that's neither valid JSON nor recognizably HTML, all of it resolves to a single fallback DRFError with the original text (truncated past a thousand characters) rather than an exception. I've written this same rule into two other projects now, and I think it's less a coincidence and more that I've been burned enough times by error-handling code that becomes the source of the crash it was supposed to report.

Getting it onto Maven Central was its own project

The parser took an evening. Getting it published was the part that ate the rest of the week. Maven Central publishing runs through the com.vanniktech.maven.publish Gradle plugin, which wants a fully-formed POM, name, description, license, developer info, SCM URLs, all of it mandatory, plus a GPG key to sign every artifact, plus verified account credentials, all wired into GitHub Actions as secrets so a tagged release triggers the publish automatically instead of me running it by hand:

mavenPublishing {
    publishToMavenCentral()
    signAllPublications()
    coordinates(group.toString(), "drferrorhandler", version.toString())
}

One small thing I was glad I caught before it became annoying: signing every artifact is correct for a real release, and completely unnecessary friction when you're just running publishToMavenLocal fifty times while you debug your own publish config. So the signing task is conditioned to skip itself specifically when the task graph is a local publish:

tasks.withType<Sign>().configureEach {
    onlyIf {
        gradle.startParameter.taskNames.none {
            it.contains("publishToMavenLocal", ignoreCase = true) ||
                it.contains("publishMavenPublicationToMavenLocal", ignoreCase = true)
        }
    }
}

Small config, but it's the difference between iterating on your publish setup in seconds and re-entering a GPG passphrase every time you want to check whether the POM looks right.

Full API and usage examples are in the documentation.

What I'd still change

Field matching is exact string matching on the path, so items[0].name and items.name are different keys as far as the library is concerned, and if you're not sure which shape your backend produces for a given serializer, you'll find out by testing, not by the API guessing for you. I think that's the right tradeoff, guessing paths is how you get silently wrong error attribution, but it does mean the library trusts you to know your own backend's shape. And the HTML title extraction is a regex over raw markup, not a real parser, which is fine for Django's own debug pages and would probably need widening if I ever pointed this at a different framework's error pages instead.