Rate limited API clients get a different error shape and no Retry-After #65

Open
opened 2026-11-03 18:52:33 +00:00 by skippy-bot · 0 comments

Problem

When the API rate limiter denies a request it answers 429 with {"error":"Too many requests"}, a bare string under error, and discards the retry delay it already computed. Every other v1 error uses the nested envelope with message and code, so a client written against the documented shape gets nothing usable, and it has no server hint for when to retry. An unmatched path answers a third shape, {"errors":{"detail":"Not Found"}}.

Evidence

At 2f150f66:

  • lib/aprsme_web/plugs/rate_limiter.ex:50 matches {:deny, _retry_after} and drops the value, then :52 renders json(%{error: error_message}) with 429.
  • lib/aprsme_web/controllers/api/v1/json/error_json.ex:9 is the nested %{error: %{message: message, code: ...}} envelope every other v1 error uses.
  • lib/aprsme_web/controllers/error_json.ex:13 produces the %{errors: %{detail: ...}} shape for unmatched routes.
  • lib/aprsme_web/live/api_docs_live.ex:818 documents 429 with no body.

Expected

One error envelope across /api/v1, and 429 carries Retry-After from the value the limiter already has.

How to verify

Send 101 requests in a minute from one address and compare the 429 body and headers with the documented envelope. Then request an unknown path and compare the third shape.

Acceptance criteria

  • A 429 uses the same envelope as other v1 errors.
  • A 429 carries Retry-After.
  • The docs show the 429 body.

Effort: S
Lens: api

## Problem When the API rate limiter denies a request it answers 429 with `{"error":"Too many requests"}`, a bare string under `error`, and discards the retry delay it already computed. Every other v1 error uses the nested envelope with `message` and `code`, so a client written against the documented shape gets nothing usable, and it has no server hint for when to retry. An unmatched path answers a third shape, `{"errors":{"detail":"Not Found"}}`. ## Evidence At `2f150f66`: - `lib/aprsme_web/plugs/rate_limiter.ex:50` matches `{:deny, _retry_after}` and drops the value, then `:52` renders `json(%{error: error_message})` with 429. - `lib/aprsme_web/controllers/api/v1/json/error_json.ex:9` is the nested `%{error: %{message: message, code: ...}}` envelope every other v1 error uses. - `lib/aprsme_web/controllers/error_json.ex:13` produces the `%{errors: %{detail: ...}}` shape for unmatched routes. - `lib/aprsme_web/live/api_docs_live.ex:818` documents 429 with no body. ## Expected One error envelope across `/api/v1`, and 429 carries `Retry-After` from the value the limiter already has. ## How to verify Send 101 requests in a minute from one address and compare the 429 body and headers with the documented envelope. Then request an unknown path and compare the third shape. ## Acceptance criteria - [ ] A 429 uses the same envelope as other v1 errors. - [ ] A 429 carries `Retry-After`. - [ ] The docs show the 429 body. **Effort:** S **Lens:** api
Sign in to join this conversation.
No description provided.