Skip to content

fix(geocoder): stop a provider mismatch from crashing the server - #1243

Merged
TurtIeSocks merged 13 commits into
mainfrom
fix/geocoder-photon-misconfig
Aug 25, 2026
Merged

fix(geocoder): stop a provider mismatch from crashing the server#1243
TurtIeSocks merged 13 commits into
mainfrom
fix/geocoder-photon-misconfig

Conversation

@TurtIeSocks

Copy link
Copy Markdown
Collaborator

What happens today

A webhook whose nominatimUrl points at Photon without geocoderProvider: "photon" takes the process down rather than failing the request. Reported from production after #1242 shipped.

The error is TypeError: Cannot read properties of undefined (reading 'suburb').

Why

node-geocoder decides how to read a response by its shape: an array is a result list, anything else is a single result. Photon answers with a GeoJSON object, so the entire FeatureCollection is handed to _formatResult as though it were one place.

node-geocoder 4.4.1 guards its own address lookup and returns undefined fields rather than throwing. The patch ReactMap layers on top of it did not, so result.address.suburb threw.

That throw never reached geocoder()'s catch. node-geocoder resolves through bluebird's asCallback, so a throw inside _formatResult surfaces as an uncaught exception and kills the process. The try/catch reads as though every failure ends in return {}, and this one could not be caught there at all. Confirmed directly: a try/catch around the await still lets the process die.

Worth noting for anyone reading the lockfile: "node-geocoder": "^4.2.0" resolves to 4.4.1, and 4.2.0's _formatResult is unguarded. The two versions fail differently, and 4.4.1 is what actually runs.

Changes

The _formatResult patch optional-chains the address, so a response without one cannot throw.

nominatimGeocoder awaits its results and rejects when the raw body is a GeoJSON FeatureCollection, with a message naming the fix. Rejecting from an async function reaches the existing catch normally, so the operator gets a log line instead of a dead process.

photonGeocoder does the mirror check for a JSON array, so the opposite mismatch reports itself rather than returning an empty result set with no reason.

results.raw survives node-geocoder's wrapper and separates the two cleanly: an Array for Nominatim, type: "FeatureCollection" for Photon.

What this does not do

It stops the crash. It does not make a misconfigured webhook geocode. A Photon backend still requires the opt-in:

{ "webhooks": [{ "nominatimUrl": "http://127.0.0.1:2322", "geocoderProvider": "photon" }] }

Auto-switching on the response shape was considered and left out on purpose. It would let a request round-trip decide behaviour and would hide the misconfiguration permanently, rather than surfacing it once.

Testing

Four new cases drive geocoder() over a real HTTP server rather than testing the mapping in isolation, which is the gap that let this ship: both mismatched pairs, and both matched pairs to show the checks do not reject valid responses.

  • yarn lint passes
  • yarn build passes
  • yarn prettier passes
  • node --test server/test/geocoder.test.js passes 26/26

Removing the optional chaining hangs the test runner instead of failing it, because the process dies mid-run. That is the same fatality seen in production, and it is worth knowing that this particular regression would show up in CI as a timeout rather than a clean failure.

yarn test also runs server/test/rocketPokemonFiltering.test.js, which fails with No database selected for React Map Tables. That is unrelated and predates this branch: server/src/db/knexfile.cjs calls process.exit(9) at import when no schema has user in its useFor, and the test reaches it through services/state. It has failed on every CI run since it landed.

A webhook pointing nominatimUrl at Photon without setting geocoderProvider took
down the process rather than failing the request.

node-geocoder decides how to read a response by its shape: an array is a result
list, anything else is a single result. Photon answers with a GeoJSON object, so
the entire FeatureCollection was handed to _formatResult as though it were one
place. node-geocoder 4.4.1 guards its own address lookup and returns undefined
fields, but the patch ReactMap layers on top did not, so result.address.suburb
threw.

That throw never reached geocoder()'s catch. node-geocoder resolves through
bluebird's asCallback, so a throw inside _formatResult surfaces as an uncaught
exception and kills the process. The try/catch reads as though every failure
returns {}, and this one could not be caught there at all.

Three changes. The patch now optional-chains the address, so a response without
one cannot throw. nominatimGeocoder awaits its results and rejects with a
message naming the fix when the body is a GeoJSON FeatureCollection, which
rejects normally and is caught. photonGeocoder does the mirror check for a JSON
array, so the opposite mismatch reports itself instead of returning an empty
result set with no reason.

Four tests drive geocoder() over a real HTTP server for both mismatches and both
matched pairs. Removing the optional chaining hangs the runner rather than
failing it, which is the same fatality seen in production.
@Mygod

Mygod commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

The primary Photon-on-Nominatim crash is addressed, but the newly added symmetric mismatch handling remains incomplete for the application's reverse-geocoding path.

Review comment:

  • [P2] Reject Nominatim reverse objects on the Photon path — server/src/services/photonGeocoder.js:263-263
    When geocoderProvider: 'photon' is paired with a Nominatim endpoint that returns JSON for gym reverse geocoding, Nominatim /reverse returns a single result object rather than the array used by /search; the resolver invokes this path at server/src/graphql/resolvers.js:467. Array.isArray(response) therefore misses the mismatch, features becomes [], and geocoder() produces only a generic formatter error/{} instead of the actionable provider error. Detect the reverse-response shape and add a reverse regression.

The mismatch check only recognised a JSON array, which is Nominatim's /search
shape. Its /reverse answers with a single object, and that is the path
resolvers.js takes for every gym lookup, so a Photon provider pointed at a
Nominatim endpoint fell through to an empty result set and a generic formatter
failure rather than the error naming the misconfiguration.

isNominatimResponse now recognises both shapes. The test is positive: it looks
for fields a Nominatim result carries rather than treating anything without
`features` as suspect, because fetchJson returns a Response object on a failed
request and a no-match answers with {"error":"Unable to geocode"}. Neither is a
provider mismatch, and an empty result is already the right outcome for both.

The regression tests assert against photonGeocoder rather than geocoder().
geocoder() catches everything and returns {}, so a mismatch and a plain miss are
indistinguishable from there. A first attempt asserted at that level and passed
against the unfixed code, which is the reason for the inner-function tests: the
change is about which error reaches the log, and only photonGeocoder exposes it.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Right, fixed in 73d14bd. The check only recognised Nominatim's /search array and missed its /reverse object, which is the shape every gym lookup goes through.

isNominatimResponse now covers both. The test is deliberately positive, looking for fields a Nominatim result carries rather than treating anything without features as suspect: fetchJson returns a Response object on a failed request, and a no-match answers with {"error":"Unable to geocode"}. Neither is a provider mismatch, and an empty result is already the correct outcome for both, so a miss must not be reported as a misconfiguration.

Three tests: a Nominatim reverse object and a Nominatim search array both raising the provider error, and a no-match object still resolving to an empty list.

Worth flagging how the first version of those tests went, since it bears on the rest of this PR. I wrote them against geocoder() and they passed against the unfixed code. geocoder() catches everything and returns {}, so a mismatch and a plain miss are identical from the outside; the entire change is about which error reaches the log. They now assert against photonGeocoder directly, and I confirmed they fail without the fix (29/30) rather than assuming.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

The new classifier cannot observe a standard Nominatim reverse response because the request does not select JSON. Consequently, the provider-mismatch diagnosis remains broken for the scenario the commit intends to fix.

Review comment:

  • [P2] Request JSON before checking the reverse-response shape — server/src/services/photonGeocoder.js:290-290
    When photonUrl is an unmodified Nominatim base URL—the exact misconfiguration targeted here—the generated /reverse request supplies lat, lon, and limit but no output format; Nominatim defaults this endpoint to XML. fetchJson() therefore rejects while parsing the body before isNominatimResponse() can inspect this JSON shape, so the caller still receives only the generic geocoding error. Add format=json or inspect the raw content type/body, and make the regression server enforce that request contract.

…ominatim host

The classifier could not see a Nominatim reverse response, because the request
never asked for one. Nominatim's /reverse defaults to XML, verified against
nominatim.openstreetmap.org: no format parameter returns text/xml with HTTP 200.
fetchJson cannot parse that and returns undefined, so isNominatimResponse never
saw a body and the caller got the generic geocoding failure instead of the
provider error.

The reverse request now sends format=json. Photon ignores the parameter.

The forward path was broken the same way and for a different reason. Photon
serves /api and Nominatim has no such endpoint, so a Nominatim host answers 404
there whatever format is asked for, verified the same way. A Nominatim search
array could never have arrived on that path. fetchJson returns the Response
itself for any failed request, so that is now surfaced as an error naming the
URL and status rather than an empty result set. That also covers a Photon
instance returning 500, which previously resolved to no results and no reason.

The tests were the reason none of this showed: the stub answered the same
Nominatim JSON on every path, so both checks looked like they worked. The
regression server now behaves like a real Nominatim host, 404 on /api and XML on
/reverse unless format=json is asked for, and a separate test asserts the
outgoing request carries the parameter. Removing format=json fails two of them;
removing the status check fails a third.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Correct, fixed in 170258c. I checked it against the real service rather than reasoning about it, and it is worse than reported: the forward path was broken too.

Verified against nominatim.openstreetmap.org:

  • /reverse?lat=..&lon=..&limit=1 with no format returns text/xml with HTTP 200. fetchJson cannot parse that and returns undefined, so isNominatimResponse never saw a body.
  • /api?q=Denver&limit=10 returns 404. Photon serves /api; Nominatim has no such endpoint at any format, so a Nominatim search array could never have arrived on the forward path either. That half was equally unreachable and I had not noticed.

The reverse request now sends format=json, which Photon ignores. For the forward path, fetchJson returns the Response itself on any failed request, so that is now surfaced as an error naming the URL and status rather than an empty result. That also covers a genuine Photon instance returning 500, which previously resolved to no results and no reason given.

On the request contract you asked for: the regression server now behaves like a real Nominatim host rather than answering the same JSON on every path, which is precisely why neither check showed as broken. It answers 404 on /api, and XML on /reverse unless format=json is present. A separate test asserts the outgoing request actually carries the parameter, since that is the part the classifier depends on and the part I got wrong. Removing format=json fails two tests, removing the status check fails a third.

That is twice now on this PR that the tests passed against code that could not work, both times because the stub was more accommodating than the real thing. Worth weighing when you decide how much of this to trust.

🤖 Addressed by Claude Code

Validated against a running Photon instance for the first time, which
contradicted three things this mapping assumed.

format=json is reverted. Photon rejects any parameter outside its own allow list
with a 400, so the previous commit broke every reverse lookup against a
correctly configured Photon. The live instance answers: Unknown query parameter
'format'. Allowed parameters are: [include, debug, dedupe, query_string_filter,
lon, layer, limit, osm_tag, distance_sort, geometry, exclude, lang, radius,
lat]. The reverse mismatch is now recognised from the unparseable body instead,
which needs no extra parameter. fetchJson does not catch that itself: it does
`return response.json()` inside its try block, so the rejection escapes its own
catch and arrives here.

locality is Nominatim's neighbourhood, not a smaller settlement. The live index
returns locality "Wicker Park" alongside a city, and Nominatim's own answer for
the same building carries quarter "Wicker Park". A genuine hamlet does not
arrive this way at all: Bootjack comes back as osm_value=locality with its name
in `name` and no locality field. It is no longer treated as a city fallback,
which would have named a neighbourhood as a town.

district is Nominatim's suburb. For 1521 North Hoyne Avenue, Photon sends
district "West Town" and Nominatim sends suburb "West Town", the same string for
the same building. address.suburb was previously left empty on the grounds that
the two were different concepts, which the comparison disproves. Photon's
Manhattan for the Statue of Liberty matches Nominatim's suburb there too.

Both now appear in the formatted address between the street and the settlement,
where Nominatim places the levels they correspond to.

Every fixture in the tests is a captured live response rather than a
constructed one.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

I finally ran this against a live Photon index instead of a stub, and it contradicted three things at once, including the fix I pushed two hours ago. Corrected in d3d9347.

format=json is reverted, because it broke Photon. Photon rejects any parameter outside its own allow list with a 400, so that commit would have taken down every reverse lookup on a correctly configured instance. The live answer:

{"message":"Unknown query parameter 'format'.  Allowed parameters are:
 [include, debug, dedupe, query_string_filter, lon, layer, limit, osm_tag,
  distance_sort, geometry, exclude, lang, radius, lat]"}

Your diagnosis was right and the suggested remedy is not available. The reverse mismatch is recognised from the unparseable body instead, which needs no extra parameter. fetchJson does not catch that itself: it does return response.json() inside its try block, so the rejection escapes its own catch and arrives here as a throw rather than as undefined, which is the opposite of what I claimed in the previous comment. There is now a test asserting the outgoing request carries only parameters Photon accepts, so this cannot regress quietly.

Two mapping errors surfaced in the same run.

locality is Nominatim's neighbourhood, not a smaller settlement. The live index returns locality: "Wicker Park" alongside a city, and Nominatim's own answer for that building carries quarter: "Wicker Park". A genuine hamlet does not arrive that way at all: Bootjack comes back as osm_value=locality with the name in name and no locality field. Treating it as a city fallback, which is what the earlier review asked for and I implemented without checking, would name a neighbourhood as a town.

district is Nominatim's suburb. For 1521 North Hoyne Avenue, Photon sends district: "West Town" and Nominatim sends suburb: "West Town" — the same string for the same building. address.suburb was left empty on my claim that the two were different OSM concepts. That claim was wrong, and a decision was made on it. Photon's Manhattan for the Statue of Liberty matches Nominatim's suburb there too.

Both now sit between the street and the settlement in the formatted address, where Nominatim puts the levels they correspond to. Nine live queries, forward and reverse, now produce output matching the captured Nominatim structurally, including neighbourhood=Golden Triangle and suburb=Manhattan exactly.

Every fixture in the tests is now a captured live response rather than one I constructed.

For the record on this PR: three of your findings were things my tests could not see, and in each case the reason was the same. I was validating against my model of Photon rather than against Photon, and the stub agreed with the model. The endpoint had been available to me for most of that time.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

The patch turns transient Photon HTTP failures into an invalid GraphQL list result and leaves direct district searches without the newly supported suburb value.

Full review comments:

  • [P2] Preserve a list-shaped fallback for Photon HTTP failures — server/src/services/photonGeocoder.js:321-324
    When a correctly configured Photon returns a transient non-2xx response such as 429 or 500, this newly throws. geocoder() catches it and returns {}, but Query.geocoder is declared [Geocoder]; GraphQL therefore emits Expected Iterable and nulls the field instead of preserving the previous [] result. Keep the status observable while retaining a list-shaped forward fallback, or deliberately propagate the original error.

  • [P2] Promote direct district results before reading suburb — server/src/services/photonGeocoder.js:245-245
    When Photon returns a forward result whose own properties.type is district, such as a suburb search, its label is in name and the containing-hierarchy district field is absent. resolveSelfReference() still has no district entry, so this new read produces suburb: ''; {{suburb}} formats and the GraphQL suburb field lose the selected place. Promote direct district results and cover this response shape.

…t results

Two fixes, both verified against a live Photon index.

The error fallback now matches the shape the caller's schema declares.
Query.geocoder is [Geocoder] and Gym.formatted is String, so returning {} made
GraphQL discard the field with "Expected Iterable" or "String cannot represent
value" rather than degrading to an empty answer. Surfacing a non-2xx as a throw
turned a transient Photon 429 or 500 from an empty list into a discarded field,
which is a worse outcome than the silence it replaced. The failure is still
reported: it is logged before the fallback returns.

That object fallback predates this branch and was already wrong for both
callers, but it was only reachable when the URL was missing entirely. Making
upstream failures throw is what turned a rare bug into a routine one.

The type map now includes district. A suburb searched by name comes back as its
own result, with the label in `name` and no `district` field, so the value being
searched for was the one value missing from the answer. Live index, q=West Town
Chicago: {name: "West Town", type: "district", osm_key: "place", osm_value:
"suburb"} now yields suburb "West Town" instead of an empty string.

Both regressions fail their tests without the fix: 34/35 without the district
entry, 32/35 with the object fallback restored.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Both correct, fixed in 0e168b6, and both verified against the live index rather than reasoned about.

The list-shaped fallback. You are right that surfacing a non-2xx as a throw made things worse for the common case: a transient Photon 429 or 500 went from an empty list to a discarded field. The fallback now matches the shape the caller's schema declares, [] for the forward path and '' for reverse, since Query.geocoder is [Geocoder] and Gym.formatted is String. The failure stays observable, logged immediately before the fallback returns.

Worth separating what I broke from what I found: the {} fallback predates this branch and was already wrong for both callers, but it was only reachable when nominatimUrl was missing entirely. Making upstream failures throw is what turned a rare bug into a routine one, so the regression is mine even though the defect was not.

The district promotion. Confirmed live, q=West Town Chicago:

{"name": "West Town", "type": "district", "osm_key": "place",
 "osm_value": "suburb", "city": "West Chicago Township", "county": "Cook County"}

The label is in name and there is no district field, so the value being searched for was the one value missing from the answer. district is now in the type map, and that query returns suburb: "West Town" rather than ''. A Wicker Park Chicago search now fills both levels, neighbourhood: "Wicker Park" and suburb: "West Town".

Both fail without their fix, 34/35 for the district entry and 32/35 with the object fallback restored.

This is the second finding on this PR that came from the district to suburb mapping I added yesterday, which I would not have added at all without your earlier review, and would not have got right without checking a live index. The pattern across this PR is consistent enough to be worth stating plainly: nearly every defect here came from me modelling Photon's behaviour instead of observing it.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

The crash guard and failure-shape changes are useful, but standard provider-mismatch handling still misses Photon's real request contract. The new locality mapping also does not cross the public schema and formatter boundary.

Full review comments:

  • [P2] Detect standard Photon errors on Nominatim routes — server/src/services/geocoder.js:41-41
    When a standard Photon base URL is accidentally left on the default Nominatim provider, node-geocoder calls /search for forward requests (Photon serves /api) and appends Nominatim-only parameters such as format and addressdetails on /reverse; Photon therefore returns 404/400 instead of a FeatureCollection. This raw.type check never fires, and a reverse {message: ...} error body is formatted into a blank result without the actionable mismatch warning. Detect the actual route/status/error shape and make the regression fixture enforce Photon's routes and parameter allow-list.

  • [P2] Expose locality through the public neighborhood contract — server/src/services/photonGeocoder.js:249-249
    When Photon supplies properties.locality, this writes it only as neighbourhood, while the GraphQL contract exposes neighborhood (server/src/graphql/typeDefs/map.graphql:25) and formatter() recognizes the different key neighborhoods (server/src/services/geocoder.js:15). Consequently the newly mapped Wicker Park value is unavailable both to raw GraphQL clients and to configured address formats; add the consumer-facing alias/token and cover it through the public geocoder path.

Reproduced against a live deployment. A webhook that inherits its URL from
Poracle and sets no geocoderProvider returned a single blank result, with
nothing in the log to explain it.

node-geocoder never gets far enough to receive a FeatureCollection, so the
previous check could not fire. It calls /search, which Photon does not serve,
and _forceParams puts format and addressdetails on every request, which Photon
rejects outright. A live instance answers:

  GET /search?q=..&format=json&addressdetails=1
    404 {"title":"Endpoint GET /search not found","status":404,...}
  GET /reverse?lat=..&lon=..&format=json&addressdetails=1
    400 {"message":"Unknown query parameter 'format'.  Allowed parameters
         are: [include, debug, dedupe, ...]"}

node-geocoder ignores the status and parses the body regardless, so both reach
_formatResult as an object with no address and format into an empty string. Both
shapes are now recognised, and the error names the setting that fixes it.

The regression server enforces Photon's actual contract rather than accepting
anything: it serves /api and /reverse only, answers 404 elsewhere with Javalin's
body, and rejects any parameter outside the allow list.

Separately, locality reached no consumer. node-geocoder emits `neighbourhood`,
the GraphQL schema exposes `neighborhood` and formatter() templates on
`neighborhoods`, so the value was invisible to raw clients and to configured
address formats alike. Both providers now carry the alias and formatter accepts
either spelling, which also makes the field work for Nominatim deployments,
where it had been dead for the same reason.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Both correct, fixed in c1e868e. I reproduced the first one against a live deployment before fixing it, and it is the exact failure that was reported from production.

Photon errors on Nominatim routes. You are right that the raw.type check could never fire. node-geocoder calls /search, which Photon does not serve, and _forceParams puts format and addressdetails on every request, which Photon rejects outright. From a live instance:

GET /search?q=..&format=json&addressdetails=1
  404 {"title":"Endpoint GET /search not found","status":404,
       "type":"https://javalin.io/documentation#endpointnotfound"}

GET /reverse?lat=..&lon=..&format=json&addressdetails=1
  400 {"message":"Unknown query parameter 'format'.  Allowed parameters are:
       [include, debug, dedupe, query_string_filter, lon, layer, limit,
        osm_tag, distance_sort, geometry, exclude, lang, radius, lat]"}

The Javalin body even carries a type, so the old check saw a field with the wrong value rather than no field. Both shapes are now recognised. The end-to-end reproduction that previously returned [{"formatted":""}] with nothing in the log now logs the URL and the setting that fixes it.

The regression server enforces Photon's contract as you asked, rather than accepting anything: /api and /reverse only, Javalin's 404 body elsewhere, and a 400 for any parameter outside the allow list. That stub is what makes these tests fail without the fix, 36/38.

The neighborhood contract. Also right, and worse than described: there are three spellings in play. node-geocoder emits neighbourhood, Geocoder.neighborhood is American, and formatter() templates on neighborhoods. Both providers now carry the alias and the formatter accepts either spelling, so the value reaches raw GraphQL clients and configured address formats. That also revives the field for Nominatim deployments, where it had been dead for the same reason long before this branch.

One thing your review surfaced indirectly: the parity test duplicated the _formatResult patch inline, so adding the alias to the real one made the copy drift. It is updated, with a note that the duplication is the point.

🤖 Addressed by Claude Code

Clearing the search box sends an empty string, and there is nothing to look up.
Photon rejects it with a 400 and {"message":"q parameter is required when no
include categories are specified"}, which now surfaces as an error, so a user
deleting their own input filled the log with failures the input had caused.
Whitespace-only input does the same. The request is no longer made at all.

The guard also covers a null search, which needed a second fix to reach: typeof
null is 'object', so a null was taken for a coordinate pair and sent to /reverse
with no lat or lon. That predates this branch and was only reachable through the
same empty-input path.

A coordinate pair at 0,0 is a real lookup and is still sent, so the guard cannot
be confused with a falsy coordinate.

The shared empty answer moves into a helper, since the fallback shape now has
two callers and has to stay the shape the caller's schema declares.
@Mygod

Mygod commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

The patch can misdiagnose generic upstream failures and still loses neighborhood data for quarter-shaped Nominatim responses and formatted GraphQL calls. These are observable contract and diagnostic defects.

Full review comments:

  • [P2] Narrow the Photon error signature — server/src/services/geocoder.js:73-73
    When a valid Nominatim service or its proxy returns an RFC 7807-style failure such as {"title":"Too Many Requests","status":429}, node-geocoder places it in results.raw, and this generic predicate reports the host as Photon. The log then advises changing geocoderProvider, potentially turning a transient error into a persistent misconfiguration; match Photon-specific Javalin fields or route text instead.

  • [P2] Preserve quarter-level Nominatim neighborhoods — server/src/services/geocoder.js:107-107
    When Nominatim returns neighborhood-level data as address.quarter—as in the Wicker Park response documented in photonGeocoder.js:24-27—node-geocoder leaves formatted.neighbourhood empty because it only reads address.neighbourhood. This alias therefore still publishes an empty neighborhood; include result.address.quarter as the fallback.

  • [P2] Keep the alias on formatted GraphQL results — server/src/services/photonGeocoder.js:254-254
    When webhook.addressFormat is non-empty, the GraphQL resolver passes it to geocoder(), whose formatted branch returns only formatted, latitude, and longitude (geocoder.js:162-166). This newly added alias is therefore discarded before resolving Geocoder.neighborhood, leaving the field null despite the direct mapper test; preserve the mapped fields when adding formatted and exercise the resolver path as required by AGENTS.md:9.

… to end

Three fixes from review.

The Photon diagnosis no longer fires on a generic title/status pair, which is
RFC 7807's problem-details shape: a rate-limiting proxy in front of a healthy
Nominatim answers {"title":"Too Many Requests","status":429}, and diagnosing
that as a provider mismatch advises the operator to break a working
configuration to fix a transient failure. The 404 match now requires Javalin's
own documentation URL in the body's type field, which is what a real Photon
sends and nothing else does.

The neighbourhood pair falls back to address.quarter. Nominatim reports
neighbourhood-level data under quarter for many places -- 1521 N Hoyne Ave
carries quarter "Wicker Park" and no neighbourhood key -- and node-geocoder only
reads address.neighbourhood, so both spellings came out empty for exactly the
responses the alias exists to surface.

The formatted branch keeps the mapped fields. Query.geocoder resolves the
Geocoder type's neighborhood, suburb and the rest straight off these objects,
and returning only the formatted triple made every one of them null whenever an
addressFormat was configured, which is the resolver path every webhook user
takes. The result spreads through with formatted added rather than being
rebuilt.

Each fix has a test that fails without it: the 7807 body resolving through the
generic path rather than throwing the mismatch advice, the Javalin body still
throwing it, a quarter-shaped response populating both spellings, and a
formatted call retaining neighborhood, suburb and streetNumber.
nominatimUrl already falls back to Poracle's providerURL, so a deployment can
get its geocoder URL from Poracle without restating it here. The backend type
had no such fallback, which left half the pair auto-populating and the other
half not: an operator whose URL came from Poracle still had to hand-set
geocoderProvider locally, or every request silently took the Nominatim branch.

PoracleNG now reports the backend behind providerURL as `provider`, so
geocoderProvider follows the same rule as its URL. Local config still wins,
matching how providerURL and addressFormat already behave.

Poracle also offers google and none. Neither has a ReactMap equivalent, so they
resolve to undefined rather than being mapped onto nominatim, which would claim
something untrue about the backend. The match is exact, so a differently-cased
value is ignored rather than guessed at.

`provider` is destructured out of remoteConfig whether or not it is usable.
this.provider is the *webhook* provider, an unrelated field that happens to
share the name, and letting Poracle's value through Object.assign(this, rest)
would silently overwrite it.

The resolution is a small exported function because #fetchConfig is private and
would otherwise need a stubbed HTTP round trip to reach. Four tests cover the
derivation, local precedence, the ignored backends, and the webhook provider
surviving a remote payload that carries a geocoding provider.
The fallback was unconditional, so a webhook with a local nominatimUrl and no
geocoderProvider inherited Photon from a Photon-backed Poracle while keeping its
own Nominatim URL. That is the default shape of every existing Nominatim
deployment, and it made searches and reverse lookups return {} against an
endpoint that had been working.

A URL and the protocol used to talk to it are one setting in two fields, and
splitting them across two sources cannot produce a working pair by accident.
The backend type is now inherited only when the URL was inherited with it.

An explicit local geocoderProvider still wins in either case, which is the only
way to run a backend Poracle does not report.

resolveGeocoderProvider takes a named argument object rather than a third
positional boolean, so the pairing rule is legible at the call site.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

All three correct, fixed in 70eedcd.

The narrowed signature. The title/status pair is RFC 7807's shape, not Photon's, and your failure case is the damaging one: a rate-limiting proxy in front of a healthy Nominatim would have been diagnosed with advice to change geocoderProvider, converting a transient 429 into a permanently broken config. The 404 match now requires Javalin's documentation URL in the body's type field (https://javalin.io/documentation#endpointnotfound), which a real Photon sends and nothing else plausibly does. A 7807 body now resolves through the generic path as before, and a test pins each side: the proxy body must not throw the mismatch advice, the Javalin body still must.

The quarter fallback. Both spellings now fall back to address.quarter, so the Wicker Park response that motivated the alias actually populates it. You were right that the alias as shipped published an empty string for exactly the quarter-shaped responses it existed to surface.

The formatted branch. The result now spreads through with formatted added instead of being rebuilt as a triple, so Geocoder.neighborhood, suburb, and the rest resolve on the path every webhook user with an addressFormat takes. The resolver-path test exercises a formatted call end to end over a stub server and asserts neighborhood, suburb, and streetNumber survive alongside formatted — the direct-mapper test you called out was indeed testing a path the resolver never uses.

Each fix fails its test when reverted individually: 44/45, 44/45, and 42/45.

🤖 Addressed by Claude Code

…oracle

feat(geocoder): derive geocoderProvider from Poracle's remote config
@Mygod

Mygod commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The narrowed classifier now treats transient problem JSON as a successful blank location. This breaks the public geocoder contract under the proxy-error scenario the commit explicitly targets.

Review comment:

  • [P2] Reject non-Nominatim problem bodies after narrowing — server/src/services/geocoder.js:77-81
    When a Nominatim proxy returns the RFC 7807 JSON this change targets, such as a 429 body, node-geocoder maps the object to one blank entry before this check. Falling through now makes geocoder(..., false, ...) return a GraphQL option with null coordinates rather than []; Location.jsx:198-214 can then pass [null, null] to map.panTo. Keep the Photon-specific diagnostic narrow, but reject other non-Nominatim problem bodies with a generic upstream error.

… a blank result

Narrowing the Photon signature reopened the path it had been (wrongly) closing:
an RFC 7807 problem body from a proxy in front of Nominatim fell through the
check, and node-geocoder had already mapped it onto one entry with undefined
coordinates. geocoder(..., false, ...) then resolved with that entry instead of
[], and Location.jsx pans the map to [null, null].

An object body carrying none of lat, display_name, address or place_id is now
rejected with a generic upstream error naming the body. Generic on purpose: this
is a failing upstream, not a misconfigured provider, and the earlier broad match
was reverted precisely because it advised changing geocoderProvider to cure a
transient 429. {"error": ...} bodies never reach the check, since node-geocoder
converts those to a thrown Error itself, and every captured /reverse fixture
carries lat, display_name and address, so genuine results pass.

The 7807 test now asserts the rejection, that its message is not the provider
advice, and that the public entry point returns [] rather than a result with
null coordinates.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Correct, fixed in 6b37fd3. Narrowing reopened the exact path the broad match had been closing, badly: the 7807 body fell through, node-geocoder had already mapped it onto one entry with undefined coordinates, and geocoder() resolved with that instead of [] — straight into Location.jsx panning to [null, null].

An object body carrying none of lat, display_name, address or place_id now rejects with a generic upstream error that names the body. Generic on purpose, per your previous finding: a failing upstream is not a misconfigured provider, so the message carries no geocoderProvider advice. {"error": ...} bodies never reach the check — node-geocoder converts those to a thrown Error itself — and all 42 captured Nominatim fixtures carry lat, display_name and address, so genuine reverse results pass.

The test now asserts three things: the rejection fires, its message is not the provider advice, and the public entry point returns [] rather than a result with null coordinates. Fails 44/45 without the guard.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Will review the new commit in a bit. This is for the old.

The patch improves failure shapes but still turns Nominatim problem responses into invalid successful results and mislabels transient Photon failures as provider misconfiguration. Both issues affect normal operational failure paths.

Full review comments:

  • [P2] Reject Nominatim problem bodies before formatting — server/src/services/geocoder.js:77-83
    When a valid Nominatim deployment is rate-limited with a JSON problem response such as {"title":"Too Many Requests","status":429}, node-geocoder ignores the HTTP status and this non-Photon path lets the object through. geocoder() consequently returns one blank Geocoder with null coordinates rather than [], allowing the autocomplete to offer an unusable location. Treat non-Nominatim problem bodies as generic upstream failures while reserving provider-specific advice for Photon signatures, and exercise this through the public GraphQL path as required by AGENTS.md:9.

  • [P2] Keep transient Photon failures out of provider diagnostics — server/src/services/photonGeocoder.js:331-334
    When a correctly configured Photon instance returns 429/500, or an authentication proxy returns 401/403, fetchJson() supplies a Response and this unconditional branch advises removing geocoderProvider: "photon". Although the caller retains the correct fallback shape, the log misdiagnoses an operational failure as bad configuration and can prompt operators to break a working setup; reserve that advice for provider-specific evidence such as the Nominatim /api 404 and report other statuses as ordinary upstream failures.

@Mygod

Mygod commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The tested problem body is rejected, but the presence-only classifier still accepts malformed or extended problem bodies that produce the same null-coordinate result. The intended failure-path fix is therefore incomplete.

Review comment:

  • [P2] Validate field values before accepting Nominatim bodies — server/src/services/geocoder.js:99-103
    When a problem response includes any one of these generic keys—RFC 7807 permits extension members, for example {"title":"Too Many Requests","status":429,"address":null}—this OR check accepts it as Nominatim. node-geocoder then formats it into an entry with undefined coordinates, so geocoder(..., false, ...) again exposes null GraphQL coordinates and Location.jsx can pan to [null, null]. Validate a complete, usable coordinate pair rather than key presence.

…f provider advice

Two fixes from review.

The Nominatim body classifier validates coordinate values instead of key
presence. RFC 7807 permits extension members, so {"title":"Too Many Requests",
"status":429,"address":null} passed the presence check and still formatted into
an entry with undefined coordinates, reopening the null-coordinate result the
check exists to prevent. A body is accepted only when lat and lon both parse to
finite numbers, which every one of the 42 captured fixtures does and which is
also the minimum a result must carry to be usable by any caller.

Photon's non-2xx handling reserves provider advice for provider evidence. Only
a 404 on /api says anything about what the host is; a 429 or 500 from a healthy
but struggling Photon, or a 401/403 from an authentication proxy in front of
one, is an operational failure, and telling the operator to remove
geocoderProvider there prompts them to break a working configuration. Other
statuses now report as an upstream failure with no configuration advice.

Non-vacuity confirmed for both: restoring the presence check fails one test,
restoring the advice on non-404 statuses fails another. A matched-pair test also
pins that a genuine reverse result passes the coordinate validation, so the
stricter check cannot silently reject what it protects.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Both remaining findings fixed in fb398d7. (The first item of the earlier comment was the fall-through that 6b37fd3 addressed; the two below are the ones still open.)

Coordinate validation over key presence. You are right that the presence check was defeatable by design: RFC 7807 extension members mean {"title":"Too Many Requests","status":429,"address":null} walked straight through and reopened the null-coordinate result. The classifier now accepts a body only when lat and lon both parse to finite numbers — which all 42 captured fixtures do, and which is also the minimum a result must carry to be usable by any caller regardless of what else is in it. The extended-body case is pinned by a test through both nominatimGeocoder (generic rejection, no provider advice) and the public geocoder() path ([]), and a matched-pair test pins that a genuine reverse result still passes, so the stricter check cannot silently reject what it protects.

Transient Photon failures. Provider advice is now reserved for provider evidence: only the 404 on /api says anything about what the host is. A 429/500 from a struggling Photon or a 401/403 from an authentication proxy reports as answered <status>; the upstream geocoder is failing, not misconfigured — no geocoderProvider mention. Tested across all four statuses, asserting the advice is absent and the status is named; the 404 path keeps its existing test.

Non-vacuity confirmed for both: restoring the presence check fails one test (52/53), restoring the advice on non-404 statuses fails another (52/53).

🤖 Addressed by Claude Code

@Mygod
Mygod requested a balanced review from Copilot August 25, 2026 02:46
@Mygod

Mygod commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The patch introduces a public provider-field asymmetry and false-positive provider diagnostics for valid or operational Photon responses. These can return inaccurate API data and prompt operators to break a valid backend configuration.

Full review comments:

  • [P2] Stop treating every 404 as a Photon mismatch — server/src/services/photonGeocoder.js:338-340
    photonGeocoder enters this branch for both /api and /reverse, so a reverse lookup receiving 404 is incorrectly reported as a failure from /api. Moreover, Photon's documented reverse-only mode intentionally returns 404 for /api while serving /reverse, making the “not a Photon instance” conclusion and removal advice wrong for a valid deployment. Report an endpoint/capability failure unless there is positive evidence of a provider mismatch.

  • [P2] Keep generic JSON parse failures out of provider advice — server/src/services/photonGeocoder.js:317-319
    When isReverse is false, this catch is handling a parse failure from /api, yet it always explains Nominatim's /reverse XML behavior and recommends removing geocoderProvider. A correctly configured Photon endpoint or proxy returning a transient 200 HTML or truncated body follows the same path, turning an operational parse failure into harmful configuration advice. Restrict the mismatch diagnosis to identified reverse XML and keep other parse failures generic.

  • [P2] Normalize provider before spreading formatted results — server/src/services/geocoder.js:204-206
    When addressFormat is truthy, this new spread exposes node-geocoder's synthetic provider: 'openstreetmap' through Geocoder.provider, while formatPhotonFeature() never supplies a provider, so equivalent Photon results resolve that public field as null. The parity test calls _formatResult directly and misses the later _format() step that adds this key. Add a Photon provider value or omit the field consistently before spreading.

@TurtIeSocks
TurtIeSocks merged commit 5301786 into main Aug 25, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens geocoding failures while expanding provider detection and address mapping.

Changes:

  • Detects mismatched Photon/Nominatim responses safely.
  • Improves neighborhood mapping and fallback result shapes.
  • Inherits Poracle provider metadata and adds integration coverage.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
server/src/services/geocoder.js Adds validation, aliases, and schema-safe fallbacks.
server/src/services/photonGeocoder.js Detects mismatches and expands address mapping.
server/src/services/Poracle.js Inherits geocoder provider metadata.
packages/types/lib/config.d.ts Documents provider inheritance.
server/test/geocoder.test.js Adds provider and HTTP integration tests.
Suppressed comments (1)

server/src/services/photonGeocoder.js:341

  • This branch also handles reverse requests, but its message always claims that /api returned 404. When isReverse is true the requested route was /reverse, and that 404 does not establish that the host lacks Photon's /api; the current diagnostic can therefore send operators toward the wrong configuration change. Name the actual requested path and avoid the unsupported conclusion.
    if (response.status === 404) {
      throw new Error(
        `${photonUrl} answered 404 for Photon's /api endpoint, so it is not a Photon instance. Check the URL, or remove "geocoderProvider": "photon" from this webhook.`,
      )

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +15 to 16
/{{(streetNumber|streetName|city|state|country|zipcode|latitude|longitude|countryCode|neighborhoods|neighborhood|neighbourhood|suburb|town|village)}}/g,
(a, b) => result[b] || '',
Comment on lines +317 to +319
throw new Error(
`${photonUrl} returned a body that is not JSON. Nominatim answers /reverse with XML unless asked otherwise, so this is usually a Nominatim URL on the Photon provider. Remove "geocoderProvider": "photon" from this webhook, or point the URL at a Photon instance. (${err instanceof Error ? err.message : err})`,
)
Comment on lines +283 to +287
this.geocoderProvider = resolveGeocoderProvider({
remote: provider,
local: this.geocoderProvider,
usingRemoteURL,
})
Comment on lines +249 to +250
neighbourhood: properties.locality || '',
// The same value under the spelling the GraphQL schema and formatter use.
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.51.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants