backendsecurity

Debugging the LGPD Export: From Opaque 409 to Clear Retry-After

· 5 min read

This week at MoWave One, we tackled a persistent user complaint: “My LGPD data export never arrives.” For a while, this was a perplexing issue. From our perspective, the service was behaving exactly as designed. However, from the Lima app user’s perspective, their request for personal data simply vanished, making a perfectly functional feature appear broken. This post details how we diagnosed and fixed the opaque behavior of our LGPD export cooldown.

The Problem: “Export is Broken, Nothing Arrives”

Lima provides a synchronous LGPD data export endpoint at GET /api/v1/lgpd/export. This endpoint is designed to return a 200 OK with the full JSON payload for 14 data modules, complete with a Content-Disposition: attachment header. There’s no job ID, no asynchronous processing; it’s a direct download. This is convenient for small exports but, as we’ll discuss, has its own challenges for larger data sets.

Users, particularly those who were trying to export their data multiple times in a short period, would report that the export simply wouldn’t initiate. They’d click the button, wait, and nothing would download. The app’s UI offered no feedback beyond the typical loading state, eventually timing out or simply doing nothing. This created a frustrating experience where a perfectly valid request seemed to fail silently every time they retried.

The Technical Cause: An Opaque Cooldown

Digging into the server logs revealed the real story. The GET /api/v1/lgpd/export endpoint enforces a cooldown: one export per user per hour. This cooldown is managed using Redis, specifically by attempting a setIfAbsent operation. If the key already exists (meaning an export occurred within the last hour), our code would throw an IllegalStateException. Our generic exception handler then mapped this IllegalStateException to a 409 INVALID_STATE HTTP response.

Here’s the critical part: the 409 response offered no additional information. It didn’t say why the state was invalid, nor did it suggest when the user could try again. From the app’s point of view, a 409 INVALID_STATE is just another flavor of “something went wrong on the server.” The client couldn’t differentiate between a genuine server error, a temporary hiccup, or a perfectly valid rate limit. Every subsequent retry by the user, within that one-hour window, would simply hit the same opaque 409.

Our Redis check looked something like this (simplified):

// Inside LgpdService.java
public JsonNode exportUserData(String userId) {
    if (redisClient.setIfAbsent(userId + ":lgpd_export_cooldown", "locked", 3600)) {
        // proceed with export logic
    } else {
        throw new IllegalStateException("LGPD export cooldown active");
    }
}

And the generic exception handling:

// Inside GlobalExceptionHandler.java
@ExceptionHandler(IllegalStateException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleIllegalStateException(IllegalStateException ex) {
    return new ErrorResponse("INVALID_STATE", ex.getMessage());
}

This generic mapping was the root of the problem. It treated a specific business rule (cooldown) as a general application state error, stripping away any useful context for the client.

The Fix: Clearer Signals with Retry-After

On 2026-07-12, we deployed a fix to address this ambiguity. The core of the solution was to introduce a dedicated exception for the LGPD export cooldown and map it to a more appropriate HTTP status code with a Retry-After header.

First, we created a specific LgpdExportCooldownException. Then, we updated GlobalExceptionHandler to catch this new exception and return 429 LGPD_EXPORT_COOLDOWN along with a Retry-After header. This header contains the exact number of seconds the user needs to wait before trying again. For the edge case where Redis might return a null TTL, we default Retry-After to 3600 seconds (one hour).

// Inside GlobalExceptionHandler.java (simplified for brevity)
@ExceptionHandler(LgpdExportCooldownException.class)
public ResponseEntity<ErrorResponse> handleLgpdExportCooldownException(LgpdExportCooldownException ex) {
    HttpHeaders headers = new HttpHeaders();
    headers.add("Retry-After", String.valueOf(ex.getRemainingSeconds()));
    return new ResponseEntity<>(new ErrorResponse("LGPD_EXPORT_COOLDOWN", ex.getMessage()), headers, HttpStatus.TOO_MANY_REQUESTS);
}

Crucially, we also ensured that the cooldown is automatically cleared if the export process itself fails. This prevents a transient backend issue from locking a user out for an hour after a failed export attempt. The generic 409 INVALID_STATE now only remains for truly rare, fail-closed scenarios, such as when Redis itself is unreachable, which would prevent the cooldown check from even happening.

After these changes, our LgpdService now boasts 94% line and 81% branch coverage, with GlobalExceptionHandler at 97% line and 89% branch coverage, and all 144 tests passing. This gives us confidence in the robustness of the new behavior.

One known backlog item remains: a multi-MB synchronous export can still hit client timeouts for long-tenured users. The documented escape hatch for this is to migrate to an asynchronous flow (202 Accepted + jobId + email delivery), which is a larger architectural change we’ve deferred for now.

Takeaway

This incident reinforced a fundamental principle: rate limits and cooldowns are not just backend enforcement mechanisms; they are user experience surfaces. If your client application cannot distinguish between a legitimate “blocked” state and a general “broken” state, and cannot inform the user how long to wait, your perfectly correct backend behavior will manifest as an outage from the user’s perspective. Providing clear, actionable feedback, like a Retry-After header, transforms a frustrating mystery into a transparent instruction. You can learn more about Lima and how we build our features at https://getlima.app.