1. The role of timestamp and requestId
Sometimes it seems that a good error response is one that crams in “everything at once”: time, identifiers, the server name, and the name of the cat that brought production down. The problem is that an error contract is a public agreement. We can extend ProblemDetail through extension fields, but we have to do it very carefully, otherwise the client ends up in a world where “today the field exists, tomorrow it does not, and the day after tomorrow it is called something else”.
timestamp and requestId are exactly the fields that usually provide real value at a small cost. timestamp helps us understand when the error was created (and correlate it with logs/metrics even in a simple project). requestId helps link a specific response that the client saw with a specific log entry in the service logs. And this is the important part: their value is not that they “look nice in JSON”, but that they make support and diagnostics less like archaeology.
We need to hold two ideas at the same time. The first is that the base contract is already enough: type/title/status/detail/instance + code solve most problems. The second is that if we add timestamp and requestId, then we add them everywhere in the same way, so we do not get the effect of “some errors are normal, others look like they came from a different project”.
So this is not about a new error format, but about two cross-cutting fields that complement the already assembled contract.
A small reference table (not as dogma, but as a guide):
| Field | Source | Required? | Why |
|---|---|---|---|
| type | Problem Details | yes | problem classification (type) |
| title | Problem Details | yes | short stable name |
| status | Problem Details | yes | status inside the response body |
| detail | Problem Details | yes | specifics of the current case |
| instance | Problem Details | yes | the request “address” (usually the path) |
| code | our application | yes (by project rules) | stable scenario code |
| fieldErrors | our application | no | field-level details, if any |
| timestamp | our application | no | when the response was created |
| requestId | our application | no | correlation of the response with logs |
2. The timestamp field
Funny as it sounds, the timestamp field often turns into a religious debate at the level of “which tab is better — two spaces or four”. In practice, it is simpler: if you decide to add timestamp, the main goal is to provide a machine-readable and stable time marker that can be compared, logged, sorted, and searched without guessing the format. And here, “human beauty” usually loses to engineering predictability.
The first rule is: do not use local formats and “pretty strings”. A string like "21.03.2026 12:34:56" looks cozy, but it immediately raises questions: which time zone? which locale? where are the seconds, where are the minutes? why dots instead of colons? That is why APIs almost always choose ISO-8601. In Java, the most natural type for this is Instant (UTC timestamp), and its toString already returns an ISO string.
If we add timestamp as an extension field in ProblemDetail, it looks literally like one line:
import java.time.Instant;
import org.springframework.http.ProblemDetail;
// Add timestamp as an extension field in ProblemDetail.
// Store it as an ISO-8601 string so the format does not depend on serialization settings.
pd.setProperty("timestamp", Instant.now().toString());
Notice a nice little detail: we store a string, not a Date and not a “custom time object”. Yes, Jackson can serialize Instant, but for a learning project and for format stability, it is easier and clearer to store the ISO string explicitly. That means fewer surprises, especially if someone later starts changing global time serialization settings (and that does happen sometimes — not out of malice, just because it “seemed more convenient”).
Another useful nuance: timestamp is the time when the error response was created, not the time when the problem happened in the world. If your file system broke, the problem itself may have happened earlier, but timestamp answers the question: “when did the server generate this response?” In our Task Tracker API, that is a perfectly honest and sufficient semantic meaning.
If you ever catch yourself thinking, “what if we also add serverTimeZone, serverName, environment, nodeId…”, stop and ask yourself: is this really needed by the client? Or is it better kept in the logs? In most ordinary REST APIs, this is more of an internal diagnostic detail that the client does not need to see.
3. The requestId field
If timestamp answers the question “when”, then requestId answers the question “where exactly did this happen in our logs”. Imagine a typical situation: the client (or frontend) sends you a bug report — “sometimes when creating a task we get a 500”. That is a bit like telling a doctor, “sometimes I feel bad”. Sympathy is there, but treatment is hard. But if the message includes requestId, it starts to look like “here is my ticket number, please check the journal by it”.
It is important to understand that requestId is not a user ID, not a task ID, and not a business identifier at all. It is a technical identifier for a specific HTTP request. It should be unique enough for you to quickly find the corresponding log lines. In practice, this is often a UUID.
Where do we get it from? There are two popular approaches, and both can be combined:
- We can accept requestId from the client (for example, from the X-Request-Id header), if the client itself knows how to set correlation identifiers. This is useful when a request passes through several systems and they all already have a shared ID. But at the same time, the server should not blindly trust everything that comes in: at minimum, limit the length and do not allow a megabyte of text to be stuffed into requestId.
- And the second approach is to generate requestId on the server if the client did not send one. For a learning project, this is absolutely a normal baseline: the client does not have to do anything, and the server still issues an ID.
Another practical point: many teams add requestId not only to the error body, but also to the response header (also X-Request-Id). That is convenient because the frontend and API Gateway often log headers more easily than they parse the body of the response. In our course, this can be seen as a “nice bonus”, but not as a mandatory infrastructure system.
4. Generating requestId in Spring Boot
The clearest way to get a requestId for every request in a Spring MVC application is to set up a small servlet filter that runs before controllers and before @ControllerAdvice. This filter either takes the ID from a header or generates a new one, then stores it in two places: in the request attribute (so it is easy to get from HttpServletRequest) and in MDC (so the logger can automatically pick it up in every log line).
Let us start with a simple piece: take the ID from the header or create a new one.
import java.util.UUID;
import jakarta.servlet.http.HttpServletRequest;
// Take requestId from the client (if it knows how to set it).
String requestId = request.getHeader("X-Request-Id");
// If the header is missing (or empty), generate a new identifier on the server side.
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
Now we want this ID to be available in code and in logs. The request attribute is the most direct path: it is convenient to read in @ExceptionHandler.
import jakarta.servlet.http.HttpServletRequest;
// Save requestId as a request attribute so we can later get it in error handlers.
request.setAttribute("requestId", requestId);
And MDC is a little “pocket for the current thread” that the logger (if the pattern is configured) can mix into each log line. Even if you do not configure logging right now, the idea is easy to explain: “put requestId there so it travels with this request’s logs”.
import org.slf4j.MDC;
// Put requestId into MDC so the logger can automatically add it to log lines.
MDC.put("requestId", requestId);
And now the most important part, where people often trip: MDC must be cleared, otherwise in a thread pool you may get a “requestId ghost” from the previous request. So we clean up in finally.
import org.slf4j.MDC;
try {
chain.doFilter(request, response);
} finally {
// Important: clear MDC, otherwise requestId may "leak" into the next request on this same thread.
MDC.remove("requestId");
}
Let us assemble this into the simplest possible filter. Yes, it is a little longer than the “ideal 10 lines”, but every line here does something, and that is a good learning compromise:
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1) Take requestId from the header (if the client sent one), otherwise generate a UUID.
String requestId = request.getHeader("X-Request-Id");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
// 2) Make requestId available inside request handling (for example, in @ExceptionHandler).
request.setAttribute("requestId", requestId);
// 3) Add requestId to MDC so it is automatically included in logs.
MDC.put("requestId", requestId);
// 4) Duplicate requestId in the response header — convenient for the frontend and proxies.
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(request, response);
} finally {
// 5) Always clear MDC (important when using a thread pool).
MDC.remove("requestId");
}
}
}
All that is left is to register the filter. In Spring Boot, it is often enough to declare a bean:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RequestIdConfig {
@Bean
RequestIdFilter requestIdFilter() {
// Register the filter in the Spring context so it applies to all requests.
return new RequestIdFilter();
}
}
Why is a filter a good place? Because it is guaranteed to run both for normal requests and for requests that fail before reaching the controller (for example, during JSON deserialization). That is exactly what we need: requestId should be available even when “nothing has started yet”.
5. Enriching ProblemDetail
The most common problem with extra fields is not “how to add them”, but “how to add them so they do not disappear in half the responses”. If you manually add timestamp in one @ExceptionHandler and forget it in another, the client again gets two different error contracts. So our main focus is to centralize the addition of fields.
In the Task Tracker API, we already have a GlobalExceptionHandler based on @ControllerAdvice. A great idea is to create a small method that “enriches” ProblemDetail with standard extension fields. Then each exception handler will look the same: build the base ProblemDetail, and then call enrich.
Important: enrich(...) does not replace the base set of type/title/status/detail/instance/code. It adds only the cross-cutting fields to the already assembled ProblemDetail, which are equally useful for 404, 400, 409, and framework errors.
Example of such a helper method:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ProblemDetail;
import java.time.Instant;
private void enrich(ProblemDetail pd, HttpServletRequest request) {
// requestId is taken from the request attribute: our filter placed it there.
Object requestId = request.getAttribute("requestId");
if (requestId != null) {
pd.setProperty("requestId", requestId);
}
// timestamp is the time when this particular response was created (not "the moment the world broke").
pd.setProperty("timestamp", Instant.now().toString());
}
Now, in the domain exception handler, the code becomes short and does not drift from method to method:
import com.example.tasktracker.api.dto.error.ApiErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.net.URI;
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex, HttpServletRequest request) {
// First, build the same base error structure as before.
ProblemDetail pd = appProblem(
HttpStatus.NOT_FOUND,
URI.create("/problems/task-not-found"),
"Task not found",
"Task with id " + ex.getTaskId() + " was not found",
ApiErrorCode.TASK_NOT_FOUND,
URI.create(request.getRequestURI())
);
// Then centrally add diagnostic fields.
enrich(pd, request);
return pd;
}
But there is another subtle, production-like detail: some errors are handled not by our @ExceptionHandler methods, but by the base mechanics of ResponseEntityExceptionHandler. For example, malformed JSON or mismatched parameter types may go to standard handlers. If we want timestamp and requestId to appear there too, we need to choose one place through which these responses pass.
One convenient option is to override handleExceptionInternal and, if the body is a ProblemDetail, add our extension fields to it. Here I use MDC.get, because at this point we do not always have HttpServletRequest directly at hand:
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
// If the framework has already created a ProblemDetail, then we add our fields to it.
if (body instanceof ProblemDetail pd) {
pd.setProperty("timestamp", Instant.now().toString());
// requestId is taken from MDC (the filter put it there at the start of the request).
String requestId = MDC.get("requestId");
if (requestId != null) {
pd.setProperty("requestId", requestId);
}
}
return super.handleExceptionInternal(ex, body, headers, status, request);
}
The point of this trick is very practical: you “bake in” the required extensions into the common pipeline once, and then you do not get format bugs like “malformed JSON returned a ProblemDetail without requestId, and support is crying again”.
The important thing here is not to overdo it. We are not building a full distributed tracing infrastructure, not bringing in OpenTelemetry, and not drawing diagrams worthy of a dissertation. We just want: every error → one format → the same fields → the same names → the same meaning.
6. Anti-patterns in the error contract
A very tricky thing: you can honestly think, “we use ProblemDetail, so everything is fine,” and still make the API inconvenient and unpredictable. Anti-patterns usually appear not from bad intent, but from a series of “it is faster this way” or “the client will have an easier time if it is always 200”. Let us discuss the most common pitfalls, especially dangerous at the stage of adding timestamp/requestId.
One of the most destructive anti-patterns is masking errors as success. It looks roughly like this:
import java.util.Map;
// bad: 200 OK with an "error" body
return Map.of("status", 200, "message", "Task not found");
At the human level, this is “well, it is kind of understandable,” but at the client-code level it is a disaster. The client sees 200 and thinks “success”, starts handling the response as successful, and then crashes somewhere deeper in another place. The status code is part of the contract, and if it lies, the contract is already broken.
The second anti-pattern is an error as a single string. Even if you return the correct 400 status, but the body is something like "invalid request" or {"message":"invalid request"}, you force the client to guess. In our project, we deliberately introduced code and fieldErrors so we do not have to parse text. So “a single string” is a step backward.
The third anti-pattern is inconsistent field names. Today you used "requestId", tomorrow in one handler you wrote "reqId", and the day after that in another one — "correlationId". In the end, the client needs to implement three different handling paths. The funny part is that the server may think this is a tiny invisible detail, while for the client it is three different contracts.
The fourth anti-pattern is different time formats in timestamp. In one place "2026-03-21T12:34:56Z", in another "2026-03-21 12:34:56", in a third "Sat Mar 21 12:34:56 UTC 2026". The client again has to guess, and if it builds diagnostics or rendering, everything becomes unstable. Time should be in the same format everywhere, and ISO-8601 is the most pragmatic option.
The fifth anti-pattern is leaking internal details. In a burst of honesty, it is very easy to do this:
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
ex.getMessage() // bad: raw message often contains internals
);
The problem is that ex.getMessage() may contain class names, file paths, technical details — everything we do not want to turn into a public contract. For the user and the client, we need wording in API terms, not in Java stack terms.
The sixth anti-pattern is “extra fields for the sake of extra fields”. Once we learn that we can use pd.setProperty(...), there is a temptation to turn the error response into a storage room: add debugInfo, internalErrorCode, stackTrace, exceptionClass, serverThread. That makes the error long, noisy, and often dangerous. If a field is not needed by the client to make a decision or for normal diagnostics via support, then it belongs in the logs, not in the public contract.
The seventh anti-pattern is a partial contract. For example, requestId was added only in TaskNotFoundException, but was forgotten for validation errors, and now the frontend cannot correlate validation problems with logs. Or vice versa: requestId was added for validation, but forgotten in a 409 conflict. To the client, this looks like “sometimes the server is good, sometimes strange”. That is exactly why we made ProblemDetail enrichment centralized.
Examples of application/problem+json responses
Now let us put together what this will look like in a real response. I will show the JSON form as the client sees it. Remember that this is application/problem+json, and the fields code, timestamp, requestId, fieldErrors are our extension fields.
Example of 404 Not Found for a missing task:
{
"type": "/problems/task-not-found",
"title": "Task not found",
"status": 404,
"detail": "Task with id 2dfc2e73-3c87-4b84-bb78-6b9f88c9c1a1 was not found",
"instance": "/api/v1/tasks/2dfc2e73-3c87-4b84-bb78-6b9f88c9c1a1",
"code": "TASK_NOT_FOUND",
"timestamp": "2026-03-21T12:34:56Z",
"requestId": "7f3c4f54-2a9e-4b1a-9c41-3a3d8dcb6f8a"
}
Example of 400 Bad Request for validation failure, where we add fieldErrors:
{
"type": "/problems/invalid-input",
"title": "Invalid input",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{ "field": "title", "code": "NotBlank", "message": "title must not be blank" },
{ "field": "tags[1]", "code": "Size", "message": "tag length must be between 1 and 30" }
],
"timestamp": "2026-03-21T12:35:10Z",
"requestId": "a1b19d9d-0b71-4d67-8b8b-58c1edc2e5aa"
}
And an example of a fair 409 Conflict for a forbidden status transition:
{
"type": "/problems/invalid-status-transition",
"title": "Invalid status transition",
"status": 409,
"detail": "Task with status ARCHIVED cannot move to IN_PROGRESS",
"instance": "/api/v1/tasks/42/status",
"code": "INVALID_STATUS_TRANSITION",
"fromStatus": "ARCHIVED",
"toStatus": "IN_PROGRESS",
"timestamp": "2026-03-21T12:36:02Z",
"requestId": "c42c6b11-3e3b-4c6a-8f4b-0d9a61c5c6f0"
}
Notice the key point: the shape is the same, the timestamp and requestId fields are present everywhere, and the client does not need to write “if this is validation, then timestamp lives here, but if it is not found, then there”. That is the goal of a coherent contract.
7. Typical mistakes when adding timestamp and requestId
Mistake #1: requestId exists in the filter, but is not cleared from MDC.
This is the classic “why do different requests have the same requestId in the logs” problem. If a thread from the pool handled one request and then another, MDC can keep the old value. As a result, you try to find one request by requestId and find several, as if your API were a series with mixed-up characters. The fix is simple: always call MDC.remove(...) in finally.
Mistake #2: timestamp is formatted locally and varies between environments.
When the time field is assembled from LocalDateTime.now() and then formatted “somehow”, you get different strings on different machines, locales, and time zones. The client is not supposed to guess these rules. For APIs, ISO-8601 through Instant.now().toString() is the simplest choice, and you do not have to think about time zones at the contract level.
Mistake #3: requestId is sometimes in the body, sometimes only in the header, sometimes nowhere.
This usually appears “by accident”: one developer added a header, another added a body, a third added nothing. The client and support need requestId to be in one clear place. In our project, the logic is simple: the header can be returned to everyone, and in the error body requestId should always be present, if enabled.
Mistake #4: extension fields are added only in domain @ExceptionHandler, but are not added to framework errors.
A very frustrating case: you did everything beautifully for TaskNotFoundException, but malformed JSON returns a ProblemDetail without requestId, and those are exactly the errors that appear most often in the early integration stages. That is why it is useful to add fields centrally in one place (handleExceptionInternal or a shared helper), not “manually in each handler”.
Mistake #5: detail contains ex.getMessage(), and internal details leak outside.
Exception messages are often written for the developer, not for the client. They may contain class names, technical parameters, and sometimes even pieces of input data. In a public API, it is better to formulate detail separately: clear, short, and without the internal structure of the application.
GO TO FULL VERSION