Skip to content

feat(core): let a caller read an endpoint's rejection body - #1804

Merged
mergify[bot] merged 1 commit into
mainfrom
devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb
Sep 9, 2026
Merged

feat(core): let a caller read an endpoint's rejection body#1804
mergify[bot] merged 1 commit into
mainfrom
devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb

Conversation

@sileht

@sileht sileht commented Sep 4, 2026

Copy link
Copy Markdown
Member

Every verb on the HTTP client renders a non-2xx response into a
CliError and drops the body. That is right for an API where a
rejection is a failure, and wrong for one where it is an answer: the
OAuth device grant replies 400 {"error": "authorization_pending"} to
every poll until its owner approves in a browser, so a client that could
only see "the request failed" could never complete the flow. GET /v1/user has the same shape from the other side -- a refused credential
(403) and a deployment too old to serve the route (404) are different
sentences to the user, and both differ from a network failure.

post_form and get_outcome return an ApiOutcome: the decoded body
on success, and the status plus the decoded error body on a terminal
rejection. Retries are untouched, and neither a 5xx nor a rate-limited
response is ever handed back as a protocol answer, however many
retries it has used -- a gateway throttling this client with 403 Retry-After is not the endpoint refusing a credential, and a caller
reading it that way would tell the user their credential was revoked.

A rejection whose body is not the protocol's error type stays a plain
CliError, so a proxy's HTML 502 page is as diagnosable as on every
other verb. The exception is an error type that can represent an
empty one, where the status survives on its own: that is the gateway
case from the other side, a self-hosted install behind nginx answering
GET /v1/user with a bare 401 or 404, where the status is the entire
answer and the caller maps it to a sentence.

Inside the driver, tolerate_not_found: bool becomes a three-valued
OnTerminalError, because the third case is not a boolean and a third
bool would have tripped clippy::fn_params_excessive_bools. The one
ordering change it forces: the retry decision is now taken before the
error body is read, since rendering the message consumes the very bytes
the caller asked for.

post_form_no_response covers the revocation endpoint, which answers
200 with an empty body. Form encoding needs reqwest's form feature;
the device grant is the only thing in the CLI that sends one.

Fixes MRGFY-8703

@mergify
mergify Bot had a problem deploying to Mergify Merge Protections September 4, 2026 23:20 Failure
@sileht

sileht commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

This pull request is part of a Mergify stack:

# Pull Request Link
1 feat(core): let a caller read an endpoint's rejection body #1804 👈
2 feat(auth): the device authorization grant, client side #1805
3 feat(cli): mergify auth login, logout and status #1806
4 feat(core): use the stored credential, deprecate GITHUB_TOKEN #1807
5 fix(core): never send a Mergify user token to GitHub #1808

@mergify

mergify Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 All 6 merge protections satisfied — ready to merge.

Show 6 satisfied protections

🟢 🤖 Continuous Integration

  • all of:
    • check-success=ci-gate

🟢 👀 Review Requirements

  • any of:
    • #approved-reviews-by>=2
    • author = dependabot[bot]
    • author = mergify-ci-bot
    • author = renovate[bot]

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|internal|docs|style|refactor|perf|test|build|ci|chore|revert|ui)(?:\(.+\))?!?:

🟢 🔎 Reviews

  • #changes-requested-reviews-by = 0
  • #review-requested = 0
  • #review-threads-unresolved = 0

🟢 📕 PR description

  • body ~= (?ms:.{48,})

🟢 🚦 Auto-queue

When all merge protections are satisfied, this pull request will be queued automatically.

@mergify
mergify Bot requested a review from a team September 4, 2026 23:26
@sileht
sileht force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb branch from 3c5d1dd to 9ca0419 Compare September 4, 2026 23:40
@sileht
sileht force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/store-mergify-credential-os-keychain--bd91cd6d branch from 7273861 to e0609e3 Compare September 4, 2026 23:40
@sileht

sileht commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Revision history

# Type Changes Reason Date
1 initial 3c5d1dd 2026-09-04 23:40 UTC
2 rebase 3c5d1dd → 9ca0419 (rebase only) 2026-09-04 23:40 UTC
3 content 9ca0419 → 88a9d1d (raw) 2026-09-08 14:57 UTC
4 rebase 88a9d1d → b9f57b2 (rebase only) 2026-09-09 08:33 UTC

@mergify
mergify Bot had a problem deploying to Mergify Merge Protections September 4, 2026 23:41 Failure
@jd
jd force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/store-mergify-credential-os-keychain--bd91cd6d branch from e0609e3 to b0fe1bb Compare September 8, 2026 09:22
@mergify

mergify Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@sileht this pull request is now in conflict 😩

@mergify mergify Bot added the conflict label Sep 8, 2026
@sileht
sileht marked this pull request as ready for review September 8, 2026 14:47
@sileht
sileht force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/store-mergify-credential-os-keychain--bd91cd6d branch from b0fe1bb to b40327d Compare September 8, 2026 14:57
@sileht
sileht force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb branch from 9ca0419 to 88a9d1d Compare September 8, 2026 14:57
@sileht
sileht deployed to func-tests-live September 8, 2026 14:57 — with GitHub Actions Active
@mergify
mergify Bot had a problem deploying to Mergify Merge Protections September 8, 2026 14:57 Failure
@mergify mergify Bot removed the conflict label Sep 8, 2026
kozlek
kozlek previously approved these changes Sep 9, 2026
@mergify
mergify Bot requested a review from a team September 9, 2026 06:18
Base automatically changed from devs/sileht/mrgfy-8703-cli-auth-commands/store-mergify-credential-os-keychain--bd91cd6d to main September 9, 2026 08:15
@mergify
mergify Bot dismissed kozlek’s stale review September 9, 2026 08:15

The base branch was changed.

@mergify

mergify Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@sileht this pull request is now in conflict 😩

@mergify mergify Bot added the conflict label Sep 9, 2026
Every verb on the HTTP client renders a non-2xx response into a
`CliError` and drops the body. That is right for an API where a
rejection is a failure, and wrong for one where it is an answer: the
OAuth device grant replies `400 {"error": "authorization_pending"}` to
every poll until its owner approves in a browser, so a client that could
only see "the request failed" could never complete the flow. `GET
/v1/user` has the same shape from the other side -- a refused credential
(403) and a deployment too old to serve the route (404) are different
sentences to the user, and both differ from a network failure.

`post_form` and `get_outcome` return an `ApiOutcome`: the decoded body
on success, and the status plus the decoded error body on a terminal
rejection. Retries are untouched, and neither a 5xx nor a rate-limited
response is ever handed back as a protocol answer, however many
retries it has used -- a gateway throttling this client with `403
Retry-After` is not the endpoint refusing a credential, and a caller
reading it that way would tell the user their credential was revoked.

A rejection whose body is not the protocol's error type stays a plain
`CliError`, so a proxy's HTML 502 page is as diagnosable as on every
other verb. The exception is an error type that can represent an
*empty* one, where the status survives on its own: that is the gateway
case from the other side, a self-hosted install behind nginx answering
`GET /v1/user` with a bare 401 or 404, where the status is the entire
answer and the caller maps it to a sentence.

Inside the driver, `tolerate_not_found: bool` becomes a three-valued
`OnTerminalError`, because the third case is not a boolean and a third
bool would have tripped `clippy::fn_params_excessive_bools`. The one
ordering change it forces: the retry decision is now taken before the
error body is read, since rendering the message consumes the very bytes
the caller asked for.

`post_form_no_response` covers the revocation endpoint, which answers
200 with an empty body. Form encoding needs reqwest's `form` feature;
the device grant is the only thing in the CLI that sends one.

Fixes MRGFY-8703

Change-Id: I9339bdfbb74edbf3399a529e602007d96bfda074
@sileht
sileht force-pushed the devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb branch from 88a9d1d to b9f57b2 Compare September 9, 2026 08:32
@sileht
sileht deployed to func-tests-live September 9, 2026 08:32 — with GitHub Actions Active
@mergify
mergify Bot deployed to Mergify Merge Protections September 9, 2026 08:34 Active
@mergify mergify Bot removed the conflict label Sep 9, 2026
@mergify
mergify Bot requested a review from a team September 9, 2026 08:40
@mergify

mergify Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-09-09 11:43 UTC · Rule: default · triggered by merge protections
  • Checks skipped · PR is already up-to-date
  • Merged2026-09-09 11:43 UTC · at b573d7b11be0895bf7820850f466ca1198c310c1 · squash

This pull request spent 15 seconds in the queue, including 2 seconds running CI.

Required conditions to merge

@mergify
mergify Bot merged commit b573d7b into main Sep 9, 2026
22 checks passed
@mergify mergify Bot added the queued label Sep 9, 2026
@mergify
mergify Bot deleted the devs/sileht/mrgfy-8703-cli-auth-commands/let-caller-read-endpoint-s-rejection-body--9339bdfb branch September 9, 2026 11:43
@mergify mergify Bot removed the queued label Sep 9, 2026
mergify Bot pushed a commit that referenced this pull request Sep 9, 2026
A new `mergify-auth` crate holding the RFC 8628 client: open a grant,
poll until its owner approves in a browser, revoke the result.

A device grant rather than a browser redirect because the CLI runs where
there is no browser to redirect to and no port to listen on -- over SSH,
in a container, on a build machine. The user reads a code off the
terminal and approves it wherever they already have a Mergify session.

Three things the implementation takes from the server rather than
assuming:

- The verification URL. A self-hosted deployment answers with its own
  dashboard, so a client that hardcoded `dashboard.mergify.com` would
  send those users to a page that knows nothing about their grant.
- The refusal text. The `access_denied` you get at the 20-token cap
  names the cap and the fix; printing the error code instead would drop
  the only part the user can act on.
- The cadence -- within limits. `interval` and `expires_in` come from
  whatever host `--api-url` names, so both are clamped at both ends: a
  server answering `interval: 86400` would otherwise hang `auth login`
  for a day and look like a bug here, `interval: 0` would make the poll
  loop an unthrottled flood, and `expires_in: 0` is a deadline already
  behind us -- one request, then the CLI blames the user's browser for
  a number the server picked. Ours sends 5 and 600. The clamp tests
  assert literal bounds rather than the constants doing the clamping,
  which is a test that passes for any value of the constant.

The same host writes the strings, and `--api-url` is not only a flag:
`MERGIFY_API_URL` in a `.envrc`, a devcontainer or a sourced dotfile
points the CLI somewhere without the user typing anything. So the
verification URL has to parse as `http(s)` before this binary prints it
under "Open this URL to authorize the Mergify CLI" -- a `javascript:`
or `file:` URL there is a phishing line in our own voice -- and every
server-written string that reaches the terminal is stripped of control
characters, since an `ESC` in the user code can rewrite the URL already
on screen. The optional `verification_uri_complete` is dropped rather
than fatal when it fails: RFC 8628 makes it optional, so the login
still works off `verification_uri`.

An HTTP failure mid-poll is not a refusal. RFC 8628 makes only
`access_denied` and `expired_token` terminal, and a proxy's 502, a
dropped connection or an exhausted rate limit is neither -- while the
grant the user approved a second ago is still open. `poll` used to `?`
those straight out, abandoning a token the server mints and nothing
ever stores or revokes, one of the twenty a user gets. It now keeps
polling to the deadline and, if that is how the window ends, reports
the transport failure instead of blaming the browser.

`slow_down` adds five seconds per RFC 8628 §3.5, and the poll loop keeps
its own deadline as a backstop against a deployment that answers
`authorization_pending` forever -- the server's own expiry is the
`expired_token` branch and comes first in practice.

The client carries no bearer token: the three endpoints are
unauthenticated, and `revoke` already puts the secret in the form body.
A test pins that, since sending it twice would be easy to do by
accident.

`refresh_token` is declared on the token response so the field cannot
break deserialization the day it appears, and nothing reads it: the
Mergify API issues none, and using one would need a refresh grant that
does not exist server-side either.

Not wired to the CLI yet.

Fixes MRGFY-8703

Depends-On: #1804
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants