Skip to content

feat(git): Let callers map adapter errors to coded errors, like a refused host - #168

Merged
vaind merged 3 commits into
mainfrom
ivan/git-relay-refused-host
Oct 29, 2026
Merged

vaind merged 3 commits into
mainfrom
ivan/git-relay-refused-host

Conversation

@vaind

@vaind vaind commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Sentry's git endpoint (getsentry/sentry#128444) relays git requests through GitRelay with Sentry's BlacklistAdapter, so a GitHub Enterprise or self-managed GitLab host that resolves to an internal address is refused. The adapter refuses by raising Sentry's own RestrictedIPAddress. scm doesn't know that exception, so GitServer counts the refusal as unhandled_exception and re-raises it, and Sentry's view has to catch, report and answer it itself.

Approach

  • GitRelay(map_error=...) takes a function that gets an exception from the upstream request and returns either a coded error to raise in its place or None to let the original through unchanged. scm can't import Sentry's exception, so the caller decides which of its adapter's errors mean what. Without a mapper, nothing changes.
  • The relay's own handling runs first: requests and urllib3 exceptions still become ResourceGatewayTimeout, ResourceServiceUnavailable and the rest, and the relay's own coded errors (such as the client going away mid-body) pass straight through. So the mapper never sees an exception that holds the Authorization header, and can't relabel those.
  • A mapped error is raised from None. A refusal raised as urllib3 connects has a traceback that runs through urllib3 frames holding the Authorization header.
  • New coded error GitRemoteRefused (git_remote_refused) for a refused host. Its message doesn't name the host or address, like the relay's other errors.
  • GitServer answers it with a 502, next to git_remote_not_https, the other remote the relay won't connect to. A 503 there means "temporarily unavailable, the request can be retried" (resource_service_unavailable), and retrying a refused host never helps. Sentry answers this case with a 503 today; after the bump it becomes a 502.

Usage, for an adapter that refuses with HostRefused:

GitRelay(adapter=..., map_error=lambda e: GitRemoteRefused() if isinstance(e, HostRefused) else None)

Tests

A test adapter refuses the way BlacklistAdapter does, from inside urllib3's connect.

  • Mapped, the refusal becomes GitRemoteRefused with no cause, a suppressed context, and neither the token nor the address. GitServer answers it with a 502 and counts git_remote_refused rather than unhandled_exception.
  • With no mapper, or a mapper returning None, the original propagates as raised.
  • A requests error, a urllib3 error and the relay's own client_closed_request never reach the mapper.

Dropping from None, the _STATUS entry or the pass-through of the relay's own errors, or calling the mapper before the requests handling, each fails a test.

After release

feat makes this a minor release. Sentry's bump PR then passes map_error=lambda e: GitRemoteRefused() if isinstance(e, RestrictedIPAddress) else None in make_git_server() and drops the view's own except RestrictedIPAddress branch.

vaind added 2 commits October 27, 2026 23:46
Sentry builds its relay with BlacklistAdapter, which refuses a GitHub
Enterprise or self-managed GitLab host that resolves to an internal
address by raising its own RestrictedIPAddress from inside urllib3's
connect. That error is neither coded nor a requests exception, so
GitServer counted it as unhandled_exception and re-raised it, and Sentry
had to catch, report and answer it itself.

scm can't import Sentry's exception, so the caller names it in
GitRelay(refused_host_errors=...), and the relay raises GitRemoteRefused
in its place. GitServer then counts and answers it like its other
refusals. The original is dropped with "from None" because its traceback
runs through urllib3 frames that hold the Authorization header, and the
new error says nothing of the address.

The constructor checks the tuple because an except clause with anything
else fails only when a request does, as a TypeError chained to that
request's error.
A fixed list of refused-host exception types only covers that one case.
A map_error callable lets the caller turn any error of its adapter into
a coded error, while scm keeps owning the error classes and their status.

The relay's own conversions of requests and urllib3 errors, and its own
coded errors, still run first, so the mapper never sees an exception
that holds the Authorization header and can't relabel the client going
away. A mapped error is raised "from None" because a refusal raised as
urllib3 connects carries frames that hold the header.
@vaind vaind changed the title feat(git): Report a refused git host as git_remote_refused feat(git): Let callers map adapter errors to coded errors, like a refused host Oct 27, 2026
@vaind
vaind marked this pull request as ready for review October 27, 2026 22:53
@vaind
vaind requested a review from a team as a code owner October 27, 2026 22:53
…t-relay-refused-host

# Conflicts:
#	src/scm/git/relay.py
@vaind
vaind merged commit 038e702 into main Oct 29, 2026
14 checks passed
vaind added a commit that referenced this pull request Oct 30, 2026
… failures (#171)

Porting Sentry and Seer to the Ed25519 grants from #170 found gaps that
should be fixed before 3.0.0:

- A bad key raised plain `ValueError`/`TypeError` from the same calls
that parse untrusted grants, so callers couldn't tell their own
misconfiguration apart from a bad or oversized grant.
- There was no helper to make a key, so `bin/git-server` and every test
suite copied the same `cryptography` calls.
- Three permanent failures answered 502, which Seer's checkout
(getsentry/seer#9229) retries for about a minute before failing.

Follow-up to #170 (CW-2334).

## Approach

- New `scm.errors.GitGrantKeyError(SCMError, ValueError)` for every bad
key, on both sides. It isn't an `SCMCodedError`, because a bad key is
the server's fault: `GitServer` raises it from its constructor rather
than answering requests with a 403. An oversized grant still raises a
plain `ValueError`.
- New `require_private_key(Secret) -> Secret`, the signer's counterpart
to `require_public_keys`, to check a key at startup.
- New `generate_private_key() -> Secret`, which replaces the copied
`cryptography` calls in `bin/git-server` and `make_key_pair`.
- `GitServer` answers `git_remote_not_https`, `git_remote_refused` and
`resource_certificate_invalid` with a 500 instead of a 502. The
integration or host is misconfigured, so no retry helps. 502 still means
the provider or the token failed; 503 and 504 still mean a retry can
succeed.
- `GetToken` and `GitServer` now document that `get_token` may raise a
coded error, which `GitServer` answers by its code. Sentry relies on
this to answer an unreachable control silo with a 503.

Not included, as decided: a public `key_id()`, or reporting which key
matched a grant.

## Release notes

Craft uses this section for the changelog in place of the title. The
last item covers earlier PRs whose titles don't show a breaking change.

### Changelog Entry

- Raise `GitGrantKeyError` for a missing or unusable git grant key, so
callers can tell their own misconfiguration apart from a bad grant
- It's a `ValueError`, not an `SCMCodedError`. A public key that isn't a
string used to raise `TypeError`.
- `verify_grant([], token)` raises it. 2.x refused the grant with
`GitInvalidGrant` (a 403 from `GitServer`).
- Add `require_private_key` to check a signing key at startup, and
`generate_private_key` to make one for tests
- Answer a remote that isn't https, a refused remote host and an
untrusted TLS certificate with a 500 instead of a 502, since no retry
can fix them
- Document that `get_token` may raise a coded error, which `GitServer`
answers by its code, e.g. `ResourceServiceUnavailable` as a 503
- Upgrade notes for earlier 3.0.0 changes
- `GitServer`'s `get_token` must return an `scm.Secret`, or every
relayed request fails with `TypeError`
([#167](#167)).
- Once Sentry maps `RestrictedIPAddress` to `GitRemoteRefused` with
`GitRelay(map_error=...)`, its refusal of internal addresses changes
from a 503 to a 500
([#168](#168)).

## Tests

Each bad-key case raises `GitGrantKeyError` on both sides, and no
message quotes the key. That check also now works for multi-line keys:
it used to join the lines, so it never matched RSA, EC or Ed448 keys. A
generated key round-trips through `sign_grant`, `public_key_of` and
`verify_grant`. New `GitServer` tests cover the three 500s and a coded
and an uncoded error from `get_token`. The suite, ruff, mypy and prek
pass, and I smoke-tested `bin/git-server`'s key loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vaind pushed a commit to getsentry/sentry that referenced this pull request Oct 30, 2026
Bumps `sentry-scm` from 2.0.0 to
[3.0.0](https://github.com/getsentry/scm-platform/releases/tag/3.0.0).
The lockfile also picks up `cryptography` as a direct dependency of
sentry-scm; the locked `cryptography` (50.0.0) and `pyjwt` (2.15.0)
already meet 3.0.0's new floors (`>=48.0.1`, `>=2.14.0`).

No code changes are needed on master. The 3.0.0 breaking changes are all
in `scm.git` (Ed25519 grants, `GitServer(public_keys=...)`, `get_token`
returning an `scm.Secret`, `GitRelay(map_error=...)`,
`GitGrantKeyError`, and the 502 → 500 status changes), and nothing on
master uses `scm.git` yet. The only user is the git endpoint in #128444,
which is already written against 3.0.0 and only needs this bump to pass
CI. The one other change that touches master's code,
getsentry/scm-platform#169, is backward compatible for `RpcServer`'s
callers.

Landing this first lets #128444 drop its "bump sentry-scm" checklist
item after a rebase. If you'd rather keep the bump inside #128444, close
this one.

This PR needs no deploy config or secrets. `SCM_GIT_GRANT_PUBLIC_KEYS`
(Ed25519 public keys as PEM, matching Seer's signing key) comes in with
#128444.

Upstream: getsentry/scm-platform#168, getsentry/scm-platform#170,
getsentry/scm-platform#171

Refs CW-2222

<!-- junior-request-attribution:start -->
via **Ivan Dlugos**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A19a84388325b8be5736899297f85ece4 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A19a84388325b8be5736899297f85ece4)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A19a84388325b8be5736899297f85ece4/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
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.

2 participants