Skip to content

Return 409/400 instead of 500 when tag creation is rejected - #2539

Merged
p-hoffmann merged 1 commit into
webapi-3.0from
p-hoffmann/fix-211-tag-create-500
Aug 14, 2026
Merged

Return 409/400 instead of 500 when tag creation is rejected#2539
p-hoffmann merged 1 commit into
webapi-3.0from
p-hoffmann/fix-211-tag-create-500

Conversation

@p-hoffmann

@p-hoffmann p-hoffmann commented Aug 14, 2026

Copy link
Copy Markdown
Member

Problem

Reported as OHDSI/Atlas3#211. Creating a tag with a name, a tag group and an icon returns a 500 with no usable message. Both routes into that 500 are ordinary user error and should be reported as such.

Cause 1: the constraint-violation handler throws from inside itself

GlobalExceptionHandler.handleDataIntegrityViolation did:

String cause = ex.getCause().getCause().getMessage();
cause = cause.substring(cause.indexOf(DETAIL) + DETAIL.length());

That assumes the cause chain is exactly two levels deep, that the inner cause carries a message, and that the message contains a PostgreSQL Detail: section. When any of those does not hold it throws a NullPointerException out of the handler, so Spring reports a 500 instead of the 409 the handler was written to return. When the section is merely absent, indexOf returns -1 and substring(-1 + 8) silently drops the first seven characters of the message instead.

This is reachable for tags in particular because tags_name_idx is a unique index on lower(name) over the whole tag table, and tag groups are rows in that same table (TagGroupService persists them through the same Tag entity and repository), so a name colliding with any existing tag or group lands in this handler. The index keeps its original plural name from before V2.9.0.20210812164224__assets_tags_renaming.sql renamed the table.

Cause 2: a rejected tag group produces a generic 500

TagService.create threw IllegalArgumentException when a selected group has allow_custom = false. Nothing handles that type, so it fell through to the generic handler and became a 500 reading An exception occurred: java.lang.IllegalArgumentException. A tag posted with no groups at all NPE'd on the same path.

Fix

  • Walk the cause chain defensively, strip the Detail: prefix only when present, and fall back to getMostSpecificCause. A duplicate name now returns 409 with the reason.
  • Throw BadRequestAtlasException for a group that does not allow custom tags, and register that type on the existing bad-request handler. It was already treated as a 400 when it arrived wrapped in an UndeclaredThrowableException, so throwing it directly producing a 500 was inconsistent. The message names the groups that refused the tag, since the client cannot otherwise tell which selection was the problem.
  • Posting a tag with no groups is a 400 rather than an NPE.

Verification

GlobalExceptionHandlerTest covers the PostgreSQL Detail: case, a missing cause, a one-level chain, a cause with no message, and a non-PostgreSQL message with no Detail: section. Restoring the previous implementation fails four of the five, three with the original NullPointerException and one on the seven-character truncation.

Note on a hypothesis that does not hold

It has been suggested that the 500 came from the client omitting mandatory, showGroup, multiSelection and allowCustom from the create request. That cannot be the cause: TagDTO declares all four as primitive boolean, so Jackson deserializes an absent key to false, and TagDTOToTagConverter reads them through isShowGroup()-style primitive getters, so there is no unboxing to fail. Sending those fields explicitly changes nothing.

Creating a tag returned a 500 with no usable message in two situations,
both of which are ordinary user error and should be reported as such.

The constraint-violation handler assumed the cause chain was exactly two
levels deep, that the inner cause carried a message, and that the message
contained a PostgreSQL "Detail: " section. Any of those assumptions
failing threw a NullPointerException out of the handler itself, so Spring
reported a 500 rather than the 409 the handler was written to return.
When the section was simply absent, indexOf returned -1 and substring
silently dropped the first seven characters of the message instead.

This matters for tags because there is a unique index on lower(name)
across the whole tags table, which holds tag groups as well, so a name
that collides with any existing tag or group lands in this handler.

Walk the cause chain defensively, only strip the "Detail: " prefix when
it is actually present, and fall back to the most specific cause.

Separately, rejecting a tag whose group does not allow custom tags threw
IllegalArgumentException, which fell through to the generic handler and
became a 500 reading "An exception occurred:
java.lang.IllegalArgumentException". Throw BadRequestAtlasException
instead and register it on the existing bad-request handler, which
already treated it as a 400 when it arrived wrapped in an
UndeclaredThrowableException. The message now names the groups that
refused the tag, since the client cannot otherwise tell which of the
selected groups was the problem. Creating a tag with no groups at all
NPEd on the same path and is now a 400 too.

Reported in OHDSI/Atlas3#211.
@p-hoffmann
p-hoffmann merged commit b310b44 into webapi-3.0 Aug 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant